当前位置: 首页 > news >正文

C++中的右值引用与移动构造函数

1.右值引用

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vector、std::string 之类的额外开销, 也才使得函数对象容器 std::function 成为了可能。

1.1左值、右值的纯右值、将亡值、右值

要弄明白右值引用到底是怎么一回事,必须要对左值和右值做一个明确的理解。

左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、 原始字面量、Lambda 表达式都属于纯右值。

需要注意的是,字面量除了字符串字面量以外,均为纯右值。而字符串字面量是一个左值,类型为 const char 数组。例如:

#include <type_traits>
int main() {// 正确,"01234" 类型为 const char [6],因此是左值const char (&left)[6] = "01234";// 断言正确,确实是 const char [6] 类型,注意 decltype(expr) 在 expr 是左值// 且非无括号包裹的 id 表达式与类成员表达式时,会返回左值引用static_assert(std::is_same<decltype("01234"), const char(&)[6]>::value, "");// 错误,"01234" 是左值,不可被右值引用// const char (&&right)[6] = "01234";
}

但是注意,数组可以被隐式转换成相对应的指针类型,而转换表达式的结果(如果不是左值引用)则一定是个右值(右值引用为将亡值,否则为纯右值)。例如:

const char*   p   = "01234";  // 正确,"01234" 被隐式转换为 const char*
const char*&& pr  = "01234";  // 正确,"01234" 被隐式转换为 const char*,该转换的结果是纯右值
// const char*& pl = "01234"; // 错误,此处不存在 const char* 类型的左值
将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++ 中, 纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
将亡值可能稍有些难以理解,我们来看这样的代码:
std::vector<int> foo() {std::vector<int> temp = {1, 2, 3, 4};return temp;
}
std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v, 然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到, 而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

1.2右值引用和左值引用

要拿到一个将亡值,就需要用到右值引用:T &&,其中 T 是类型。 右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值, 有了它我们就能够方便的获得一个右值临时对象,例如:

#include <iostream>
#include <string>
void reference(std::string& str) {std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {std::cout << "右值" << std::endl;
}
int main()
{std::string lv1 = "string,"; // lv1 是一个左值// std::string&& r1 = lv1; // 非法, 右值引用不能引用左值std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值std::cout << rv1 << std::endl; // string,const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期// lv2 += "Test"; // 非法, 常量引用无法被修改std::cout << lv2 << std::endl; // string,string,std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期rv2 += "Test"; // 合法, 非常量引用能够修改临时变量std::cout << rv2 << std::endl; // string,string,string,Testreference(rv2); // 输出左值return 0;
}

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

注意,这里有一个很有趣的历史遗留问题,我们先看下面的代码:

#include <iostream>
int main() {// int &a = std::move(1);    // 不合法,非常量左引用无法引用右值const int &b = std::move(1); // 合法, 常量左引用允许引用右值std::cout << a << b << std::endl;
}
第一个问题,为什么不允许非常量引用绑定到非左值?这是因为这种做法存在逻辑错误:
void increase(int & v) {v++;
}
void foo() {double s = 1;increase(s);
}

由于 int& 不能引用 double 类型的参数,因此必须产生一个临时值来保存 s 的值, 从而当 increase() 修改这个临时值时,调用完成后 s 本身并没有被修改。

第二个问题,为什么常量引用允许绑定到非左值?原因很简单,因为 Fortran 需要。

2.移动构造函数

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作, 调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。 试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、 再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,例如:

#include <iostream>
class A {
public:int *pointer;A():pointer(new int(1)) {std::cout << "构造" << pointer << std::endl;}A(A& a):pointer(new int(*a.pointer)) {std::cout << "拷贝" << pointer << std::endl;} // 无意义的对象拷贝A(A&& a):pointer(a.pointer) {a.pointer = nullptr;std::cout << "移动" << pointer << std::endl;}~A(){std::cout << "析构" << pointer << std::endl;delete pointer;}
};
// 防止编译器优化
A return_rvalue(bool test) {A a,b;if(test) return a; // 等价于 static_cast<A&&>(a);else return b;     // 等价于 static_cast<A&&>(b);
}
int main() {A obj = return_rvalue(false);std::cout << "obj:" << std::endl;std::cout << obj.pointer << std::endl;std::cout << *obj.pointer << std::endl;return 0;
}

在上面的代码中:

首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;

函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

从而避免了无意义的拷贝构造,加强了性能。再来看看涉及标准库的例子:

#include <iostream> // std::cout
#include <utility> // std::move
#include <vector> // std::vector
#include <string> // std::string
int main() {std::string str = "Hello world.";std::vector<std::string> v;// 将使用 push_back(const T&), 即产生拷贝行为v.push_back(str);// 将输出 "str: Hello world."std::cout << "str: " << str << std::endl;// 将使用 push_back(const T&&), 不会出现拷贝行为// 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销// 这步操作后, str 中的值会变为空v.push_back(std::move(str));// 将输出 "str: "std::cout << "str: " << str << std::endl;return 0;
}

2.1完美的移动转发

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

void reference(int& v) {std::cout << "左值" << std::endl;
}
void reference(int&& v) {std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {std::cout << "普通传参:";reference(v); // 始终调用 reference(int&)
}
int main() {std::cout << "传递右值:" << std::endl;pass(1); // 1是右值, 但输出是左值std::cout << "传递左值:" << std::endl;int l = 1;pass(l); // l 是左值, 输出左值return 0;
}

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。 而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型

实参参数类型

推导后函数形参类型

T&

左引用

T&

T&

右引用

T&

T&&

左引用

T&

T&&

右引用

T&&

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候, 保持原来的参数类型(左引用保持左引用,右引用保持右引用)。 为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

#include <iostream>
#include <utility>
void reference(int& v) {std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {std::cout << "              普通传参: ";reference(v);std::cout << "       std::move 传参: ";reference(std::move(v));std::cout << "    std::forward 传参: ";reference(std::forward<T>(v));std::cout << "static_cast<T&&> 传参: ";reference(static_cast<T&&>(v));
}
int main() {std::cout << "传递右值:" << std::endl;pass(1);std::cout << "传递左值:" << std::endl;int v = 1;pass(v);return 0;
}

输出结果为:

传递右值:

              普通传参: 左值引用std::move 传参: 右值引用std::forward 传参: 右值引用
static_cast<T&&> 传参: 右值引用
传递左值:普通传参: 左值引用std::move 传参: 右值引用std::forward 传参: 左值引用
static_cast<T&&> 传参: 左值引用

无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发, 所以 std::move 总会接受到一个左值,从而转发调用了reference(int&&) 输出右值引用。

唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发(传递)了函数的实参给了内部调用的其他函数。

std::forward 和 std::move 一样,没有做任何事情,std::move 单纯的将左值转化为右值, std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward<T>(v) 和 static_cast<T&&>(v) 是完全一样的。

读者可能会好奇,为何一条语句能够针对两种类型的返回对应的值, 我们再简单看一看 std::forward 的具体实现机制,std::forward 包含两个重载:

template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
template<typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
{static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"" substituting _Tp is an lvalue reference type");return static_cast<_Tp&&>(__t);
}

在这份实现中,std::remove_reference 的功能是消除类型中的引用, std::is_lvalue_reference 则用于检查类型推导是否正确,在 std::forward 的第二个实现中 检查了接收到的值确实是一个左值,进而体现了坍缩规则。

当 std::forward 接受左值时,_Tp 被推导为左值,所以返回值为左值;而当其接受右值时, _Tp 被推导为 右值引用,则基于坍缩规则,返回值便成为了 && + && 的右值。 可见 std::forward 的原理在于巧妙的利用了模板类型推导中产生的差异。

这时我们能回答这样一个问题:为什么在使用循环语句的过程中,auto&& 是最安全的方式? 因为当 auto 被推导为不同的左右引用时,与 && 的坍缩组合是完美转发。

相关文章:

C++中的右值引用与移动构造函数

1.右值引用右值引用是 C11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C 中大量的历史遗留问题&#xff0c; 消除了诸如 std::vector、std::string 之类的额外开销&#xff0c; 也才使得函数对象容器 std::function 成为了可能。1.1左值、右值的纯右值、将亡值…...

Swift如何使用依赖注入进行解藕

Swift 中可以使用依赖注入&#xff08;Dependency Injection&#xff09;来解耦组件之间的依赖关系。依赖注入是一种设计模式&#xff0c;指的是在运行时&#xff0c;将一个组件所依赖的其他组件通过构造函数或者属性注入的方式传递给该组件。 例如&#xff0c;有两个组件 A 和…...

合宙ESP32S3-CORE开发板|保姆级|Arduino IDE|windows11|esp32S3支持库|helloword例程:Arduino 环境搭建

Arduino主页网址&#xff1a; Software | Arduino 以windows11版本为例&#xff1a; Arduino IDE最新版本为2.0.3 左边的按钮是直接下载&#xff08;免捐赠&#xff09;&#xff1a; 下载安装完成后&#xff0c;更改软件默认语言&#xff1a; 默认的库是不支持ESP32的&#…...

CMake中target_precompile_headers的使用

CMake中的target_precompile_headers命令用于添加要预编译的头文件列表&#xff0c;其格式如下&#xff1a; target_precompile_headers(<target><INTERFACE|PUBLIC|PRIVATE> [header1...][<INTERFACE|PUBLIC|PRIVATE> [header2...] ...]) # 1 target_preco…...

SpringCloud和微服务介绍

SpringCloud介绍 SpringCloud是在SpringBoot的基础上构建的,用于简化分布式系统构建的工具集。 该工具集为微服务架构中所涉及的配置管理,服务发现,智能路由,断路器,微代理和控制总线等操作提供了一种简单的开发方式。 SpringCloud中包含了多个子项目&#xff1a; Spring …...

Qt源码编译过程中配置文件中的选项说明

文章目录选项说明默认值顶级安装目录-prefix 部署目录&#xff0c;如目标设备上所示。/usr/local/Qt-$QT_VERSION-extprefix 安装目录&#xff0c;如主机上所示。SYSROOT/PREFIX-hostprefix [dir]主机上运行的生成工具的安装目录。如果未给定[dir]&#xff0c;则将使用当前构建…...

Mysql 增删改查(一) —— 查询(条件查询where、分页limits、排序order by、分组 group by)

查询 select 可以认为是四个基本操作中使用最为频繁的操作&#xff0c;然而数据量比较大的时候&#xff0c;我们不可能查询所有内容&#xff0c;我们一般会搭配其他语句进行查询&#xff1a; 假如要查询某一个字段的内容&#xff0c;可以使用 where假如要查询前几条记录&#…...

VScode 结合clangd 构建linux源代码阅读环境

1、背景介绍上一篇文章&#xff1a;VScode 结合Global构建linux源代码阅读环境 &#xff0c;介绍了在VS Code工具中通过remote-ssh远程登陆到Linux远程服务器&#xff0c;使用Global构建linux源代码阅读环境&#xff0c;对linux kernel代码进行解析&#xff0c;实现全局搜索、自…...

web应用 —— JavaScript

Web应用(acwing) 三、JavaScript 1.JS的调用方式与执行顺序 ①使用方法 HTML页面中任意位置加上<script type"module"></script>标签即可 常见使用方式&#xff1a; 直接在<script type"module"></script>标签内写JS代码。直…...

SSM整合SpringSecurity简单使用

一、SpringSecurity 1.1 什么是SpringSecurity Spring Security 的前身是 Acegi Security &#xff0c;是 Spring 项目组中用来提供安全认证服务的框架。(官网地址) Spring Security 为基于J2EE企业应用软件提供了全面安全服务。特别是使用领先的J2EE解决方案-Spring框架开发…...

Java零基础教程——数据类型

目录数据类型数据类型的分类运算符算术运算符符号做连接符的识别自增、自减运算符赋值运算符关系运算符逻辑运算符短路逻辑运算符三元运算符运算符优先级数据类型 数据类型的分类 引用数据类型&#xff08;除基本数据类型之外的&#xff0c;如String &#xff09; 基本数据类…...

【Linux 信号】信号的产生方式、信号的捕捉的全过程

信号的处理方式是远远比信号的产生当闹钟响了就知道时间到了&#xff0c;说明对应信号处理方法比信号产生更早操作系统的信号处理方法在编写操作系统的时候就已经编写好了signal函数1.1所有的信号1.2 signal函数的概念和简单使用捕捉信号就是自定义对应的信号的处理方法9号信号…...

代码随想录第58天(动态规划):● 392.判断子序列 ● 115.不同的子序列

一、判断子序列 题目描述&#xff1a; 思路和想法&#xff1a; 这道题目还是最长公共子序列的拓展&#xff0c;只是这里进行删除的一定是t字符串&#xff0c;当不相等时&#xff0c;dp[i][j] dp[i][j - 1]&#xff1b;其余基本一致。当最长公共子序列个数等s.size()时&#x…...

代码随想录第55天(动态规划):● 309.最佳买卖股票时机含冷冻期 ● 714.买卖股票的最佳时机含手续费

一、最佳买卖股票时机含冷冻期 题目描述: 思路和想法&#xff1a; 这道题相较于之前的题目&#xff0c;注重对状态的分析&#xff0c;这里分为四个状态。 &#xff08;1&#xff09;状态一&#xff0c;买入状态 dp[i][0] 操作一&#xff1a;前一天就是持有状态&#xff08;状…...

字符串装换整数(atoi)-力扣8-java

一、题目描述请你来实现一个 myAtoi(string s) 函数&#xff0c;使其能将字符串转换成一个 32 位有符号整数&#xff08;类似 C/C 中的 atoi 函数&#xff09;。函数 myAtoi(string s) 的算法如下&#xff1a;读入字符串并丢弃无用的前导空格检查下一个字符&#xff08;假设还未…...

毕业5年,从月薪3000到年薪40w,我掌握了那些核心技能?(建议收藏)

大家好&#xff0c;我是静静~~是一枚一线大厂的测试开发工程师很多读者私信问我&#xff0c;自己时间不短了&#xff0c;随着工作年限的不断增长&#xff0c;感觉自己的技术水平与自己的工作年限严重不符。想跳槽出去换个新环境吧&#xff0c;又感觉自己的能力达不到心仪公司的…...

C++中的并行与并发

1.1 并行基础std::thread 用于创建一个执行的线程实例&#xff0c;所以它是一切并发编程的基础&#xff0c;使用时需要包含 <thread> 头文件&#xff0c; 它提供了很多基本的线程操作&#xff0c;例如 get_id() 来获取所创建线程的线程 ID&#xff0c;使用 join() 来加入…...

h2database源码解析-如何更新一条行记录

这里的更新包括两种操作&#xff1a;删、改。更新操作涉及的内容在其他文章里面已经做过介绍了&#xff0c;本文主要是介绍更新的代码流程&#xff0c;以了解更新操作都做了哪些事情。如果有未介绍过的知识点会详细介绍。 目录改(update)如何判读是否加了行锁删(delete)改(upda…...

FyListen——生命周期监听器(设计原理之理解生命周期)

FyListen——生命周期监听器&#xff08;设计原理之理解生命周期&#xff09; FyListen 的核心原理有两个&#xff1a; 通过子Fragment对Activity、Fragment进行生命周期监听Java8 接口特性 default 1. 什么是上下文Context 这是一个装饰器模式&#xff0c; ContextImpl 是 …...

Element UI框架学习篇(六)

Element UI框架学习篇(六) 1 删除数据 1.1 前台核心函数 1.1.1 elementUI中的消息提示框语法 //①其中type类型和el-button中的type类型是一致的,有info灰色,success绿色,danger红色,warning黄色,primary蓝色 //②message是你所要填写的提示信息 //③建议都用,因为比双引号…...

华为云AI开发平台ModelArts

华为云ModelArts&#xff1a;重塑AI开发流程的“智能引擎”与“创新加速器”&#xff01; 在人工智能浪潮席卷全球的2025年&#xff0c;企业拥抱AI的意愿空前高涨&#xff0c;但技术门槛高、流程复杂、资源投入巨大的现实&#xff0c;却让许多创新构想止步于实验室。数据科学家…...

Android Wi-Fi 连接失败日志分析

1. Android wifi 关键日志总结 (1) Wi-Fi 断开 (CTRL-EVENT-DISCONNECTED reason3) 日志相关部分&#xff1a; 06-05 10:48:40.987 943 943 I wpa_supplicant: wlan0: CTRL-EVENT-DISCONNECTED bssid44:9b:c1:57:a8:90 reason3 locally_generated1解析&#xff1a; CTR…...

【WiFi帧结构】

文章目录 帧结构MAC头部管理帧 帧结构 Wi-Fi的帧分为三部分组成&#xff1a;MAC头部frame bodyFCS&#xff0c;其中MAC是固定格式的&#xff0c;frame body是可变长度。 MAC头部有frame control&#xff0c;duration&#xff0c;address1&#xff0c;address2&#xff0c;addre…...

《用户共鸣指数(E)驱动品牌大模型种草:如何抢占大模型搜索结果情感高地》

在注意力分散、内容高度同质化的时代&#xff0c;情感连接已成为品牌破圈的关键通道。我们在服务大量品牌客户的过程中发现&#xff0c;消费者对内容的“有感”程度&#xff0c;正日益成为影响品牌传播效率与转化率的核心变量。在生成式AI驱动的内容生成与推荐环境中&#xff0…...

(二)原型模式

原型的功能是将一个已经存在的对象作为源目标,其余对象都是通过这个源目标创建。发挥复制的作用就是原型模式的核心思想。 一、源型模式的定义 原型模式是指第二次创建对象可以通过复制已经存在的原型对象来实现,忽略对象创建过程中的其它细节。 📌 核心特点: 避免重复初…...

跨链模式:多链互操作架构与性能扩展方案

跨链模式&#xff1a;多链互操作架构与性能扩展方案 ——构建下一代区块链互联网的技术基石 一、跨链架构的核心范式演进 1. 分层协议栈&#xff1a;模块化解耦设计 现代跨链系统采用分层协议栈实现灵活扩展&#xff08;H2Cross架构&#xff09;&#xff1a; 适配层&#xf…...

MODBUS TCP转CANopen 技术赋能高效协同作业

在现代工业自动化领域&#xff0c;MODBUS TCP和CANopen两种通讯协议因其稳定性和高效性被广泛应用于各种设备和系统中。而随着科技的不断进步&#xff0c;这两种通讯协议也正在被逐步融合&#xff0c;形成了一种新型的通讯方式——开疆智能MODBUS TCP转CANopen网关KJ-TCPC-CANP…...

[Java恶补day16] 238.除自身以外数组的乘积

给你一个整数数组 nums&#xff0c;返回 数组 answer &#xff0c;其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。 题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。 请 不要使用除法&#xff0c;且在 O(n) 时间复杂度…...

3-11单元格区域边界定位(End属性)学习笔记

返回一个Range 对象&#xff0c;只读。该对象代表包含源区域的区域上端下端左端右端的最后一个单元格。等同于按键 End 向上键(End(xlUp))、End向下键(End(xlDown))、End向左键(End(xlToLeft)End向右键(End(xlToRight)) 注意&#xff1a;它移动的位置必须是相连的有内容的单元格…...

基于 TAPD 进行项目管理

起因 自己写了个小工具&#xff0c;仓库用的Github。之前在用markdown进行需求管理&#xff0c;现在随着功能的增加&#xff0c;感觉有点难以管理了&#xff0c;所以用TAPD这个工具进行需求、Bug管理。 操作流程 注册 TAPD&#xff0c;需要提供一个企业名新建一个项目&#…...