Re:思考·重建·记录 现代C++ C++11篇 (二) 右值引用与移动语义&引用折叠与完美转发


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️现代C++系列个人专栏: 插曲:现代C++
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录

概要&序論

这里是此方,好久不见。 本专栏是【主题曲:C++程序设计 】专栏的补充篇【插曲:现代C++ 】。本系列将优先深度解析C++11标准,力求内容详实,无微不至。C++14~C++20的进阶内容将在后续间隔一段时间后连载。本期将重点讲解:右值引用与移动语义以及他们常见的运用场景:引用折叠与完美转发等内容.好的,让我们现在开始吧。

C++98的C++语法中就有引用的语法,而C++11中新增了的右值引用 语法特性,C++11之后我们之前学习的引用就叫做左值引用无论左值引用还是右值引用,都是给对象取别名。

一,C++11后的概念扩展:左值与右值

1.1左值的定义、判断依据与举例

左值是一个表示数据的表达式(如变量名或解引用的指针),一般是有持久状态,存储在内存中,我们可以获取它的地址,左值可以出现赋值符号的左边,也可以出现在赋值符号右边。定义时const修饰后的左值,不能给他赋值,但是可以取它的地址。

关键:可以取地址

cpp 复制代码
// 左值:可以取地址,以下的p、b、c、*p、s、s[0]就是常⻅的左值
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';
cout << &c << endl;
cout << (void*)&s[0] << endl;//这里强制类型转换的原因是C++不能打印char*地址

1.2右值的定义、判断依据与举例

右值也是一个表示数据的表达式,要么是字面值常量、要么是表达式求值过程中创建的临时对象 等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。

关键:不可以取地址

右值例如: 临时变量,字面量常量,存储于寄存器中的变量等。

cpp 复制代码
// 以下几个10、x + y、fmin(x, y)、string("11111")都是常见的右值
10;//存在寄存器里面取不到地址
x + y;//存在寄存器里面取不到地址
fmin(x, y);//传值返回临时对象
string("11111");//匿名对象

这里的fmin是一个计算函数 ,用来计算两个值x,y中的较小值。不是本文重点,这里不多讲。它的返回值是传值返回生成的临时对象属于右值。 (不是所有的函数返回值都是右值,传递左值引用返回的函数传递的是对象的别名,能够取到地址,是左值)

科普内容

值得一提的是,左值的英文简写为lvalue右值的英文简写为rvalue 。传统认为它们分别是left value、right value 的缩写。

现代C++中,lvalue 被解释为locator value 的缩写,可意为存储在内存中、有明确存储地址可以取地址的对象,而 rvalue 被解释为 read value ,指的是那些可以提供数据值,但是不可以寻址

取右值的地址会报什么错?

cpp 复制代码
//error C2102 "&"要求左值
cout << &string("11111") << endl;

总结左值和右值的核心区别就是能否取地址。

二,左值引用和右值引用

2.1左值引用和右值引用的定义

cpp 复制代码
Type& r1 = x; Type&& rr1 = y; 

第一个语句就是左值引用 ,左值引用就是给左值取别名,第二个就是右值引用,同样的道理,右值引用就是给右值取别名。

2.2左右值引用交叉

直接操作不允许

  1. 左值引用不能直接引用右值
  2. 右值引用不能直接引用左值

2.2.1左值引用打破规则的方法------const 左值引用

右值不能被左值引用引用的根本在于,右值不能被修改,被左值引用后可以被修改导致权限放大。 所以加上const的左值引用就可以引用右值。

cpp 复制代码
// 左值引用不能直接引用右值,但是const左值引用可以引用右值
const int& rx1 = 10;
const double& rx2 = x + y;
const double& rx3 = fmin(x, y);
const string& rx4 = string("11111");

这也是为什么以前总是推荐你的函数参数列表写成const 左值引用的原因。这样既可以传递左值又可以传递右值。

2.2.2右值引用打破规则的方法------move 左值

move 是库里面的一个函数模板 ,本质内部是进行强制类型转换底层就像:string&& rrx5 = (string&&)s;),当然他还涉及一些引用折叠的知识,这个我们后面会细讲。

cpp 复制代码
// 右值引用不能直接引用左值,但是右值引用可以引用move(左值)
int&& rrx1 = move(b);//move不会改变左值原本的左值属性
int*&& rrx2 = move(p);
int&& rrx3 = move(*p);
string&& rrx4 = move(s);

2.3右值引用绑定右值后引发的一系列问题

2.3.1右值别名获权修改右值

需要注意的是变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量表达式的属性是左值。 既然是左值,那么就意味着可以用这个别名来修改该右值。C++11的设计初衷也正因此

右值引用变量在用于表达式时属性是左值,这个设计这里会感觉跟怪,下一小节我们讲右值引用的使用场景时,就能体会这样设计的价值了。

cpp 复制代码
int main(){
    std::string&& r3 = string("Ciallo"); 
    r3="Hello";
    return 0;
}

2.3.2右值别名延长右值生命周期

右值引用可用于为临时对象延长生命周期 ,const 的左值引用也能延长临时对象生存期。

延长声明周期的体现 :析构操作发生在函数执行结束,而非右值所在行。如下代码中r2/r3销毁,临时对象才销毁。

cpp 复制代码
int main(){
    const std::string& r2 = 1 + 1; // const 的左值引用延长生命周期
    std::string&& r3 = string("Ciallo"); //右值引用延长生存期
    return 0;
}

2.4右值引用与左值引用的底层一致

语法层面看,左值引用和右值引用都是取别名,不开空间 。从汇编底层的角度看下面代码中 Ref 和 Ref01 汇编层实现,底层都是用指针实现的,没什么区别底层汇编等实现和上层语法表达的意义有时是背离的,所以不要然到一起去理解,互相佐证!

2.5左值与右值的参数匹配

C++98中,我们实现一个const左值引用 作为参数的函数,那么实参传递左值和右值都可以匹配

C++11以后,分别重载左值引用、const左值引用、右值引用作为形参的f函数,那么实参是左值会匹配f(左值引用),实参是const左值会匹配f(const 左值引用),实参是右值会匹配f(右值引用)。


---------了解了右值引用后,我们正式开始进入移动语义的研究---------

三,移动语义的定义与意义

3.1移动语义为何出现

3.1.1C++11前返回值场景的悖论

左值引用主要使用场景是 :在函数中左值引用传参和左值引用传返回值时减少拷贝,同时还可以修改实参和修改返回对象的值。左值引用已经解决大多数场景的拷贝效率问题,但是有些场景不能使用传左值引用返回,如下

力扣-杨辉三角或者是 力扣-字符串相加这里举一个例子。 如下,先说结论两头堵死,为什么?

  1. 这个vector<vector> vv(numRows);是定义在函数内部的临时变量,离开函数后栈帧销毁,必然不能用引用返回。
  2. 这个vector<vector> vv(numRows);二维数组,拷贝消耗非常巨大,自然不能拷贝返回。

这里补充一个有趣的点,后面解释原理:

  1. 这里如果返回左值引用,有的编译器会直接报错。
  2. 这里如果返回右值引用,不会报错但是什么都没有返回。
cpp 复制代码
class Solution
{
public:
	//vector<vector<int>>& generate(int numRows)
    vector<vector<int>> generate(int numRows) {
        vector<vector<int>> vv(numRows);
        for (int i = 0; i < numRows; ++i)
            vv[i].resize(i + 1, 1);
        for (int i = 2; i < numRows; ++i) {
            for (int j = 1; j < i; ++j)
                vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
        }
        return vv;
    }
};
int main(){
    vector<vector<int>> ret = Solution().generate(5);
    return 0;
}

这种情况下,古人是怎么解决的?C++98 中的解决方案是使用输出型参数解决,如下,但是输出型参数可读性和代码维护性都比较差。

cpp 复制代码
class Solution
{
public:
    void generate(vector<vector<int>> ret,int numRows) {
        vector<vector<int>> vv(numRows);
        for (int i = 0; i < numRows; ++i)
            vv[i].resize(i + 1, 1);
        for (int i = 2; i < numRows; ++i) {
            for (int j = 1; j < i; ++j)
                vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
        }
        ret = vv;
        return ;
    }
};
int main(){
    vector<vector<int>> _ret;
    Solution().generate(_ret,5);
    return 0;
}

于是乎,为了解决这种问题,C++11带来了右值引用与移动语义

3.2移动语义的定义------从拷贝到剪切

移动语义是一种资源管理优化技术。它允许将底层资源(如动态分配的内存、文件句柄、网络连接等)从一个"即将消亡"的对象(右值)直接"转移"到另一个对象中,从而避免了昂贵且不必要的"深拷贝"。

移动语义的逻辑非常现实:既然那个临时对象马上就要死了,为什么还要费力去复制它的数据呢?直接把它的数据"偷"过来(把指针指向它的内存),然后把临时对象的指针置为空,这就完成了"移动"。

3.3移动语义的两大基石其之二:移动构造函数和移动赋值运算符

3.3.1什么是移动构造函数和移动赋值运算符重载

移动构造函数是一种构造函数,类似拷贝构造函数 ,移动构造函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用,如果还有其他参数,额外的参数必须有缺省值。

移动赋值是一个赋值运算符的重载,它跟拷贝赋值构成函数重载,类似拷贝赋值函数 ,移动赋值函数要求第一个参数是该类类型的引用,但是不同的是要求这个参数是右值引用

对于像string/vector这样的深拷贝的类或者包含深拷贝的成员变量的类,移动构造和移动赋值才有意义 ,因为移动构造和移动赋值的第一个参数都是右值引用的类型,他的本质是要"窃取"引用的右值对象的资源,而不是像拷贝构造和拷贝赋值那样去拷贝资源,从提高效率。

3.3.2移动构造和移动赋值的代码实现

把我们 以前写的my_string拿过来讲,代码链接: (先凑合用,后期会同步到Github)此方的Gittee代码仓库-string底层实现

移动构造函数的代码

cpp 复制代码
//移动构造
string(string&& Ref)
{
    cout << "yidonggouzao" << endl;
    swap(Ref);
}

移动赋值运算符重载的代码

cpp 复制代码
//移动赋值
string& operator=(string&& Ref)
{
    cout << "yidong fuzhi" << endl;
    swap(Ref);
    return *this;
}

回收伏笔 :只有右值引用的属性是左值属性,这里才能swap 修改这个引用指向的值,反之如果是右值引用是右值属性就不能修改。

右值引用指向的右值的资源全部被换走:

cpp 复制代码
void swap(string& s)
{
    std::swap(s._str, _str);
    std::swap(s._size, _size);
    std::swap(s._capacity, _capacity);
}

当同时存在拷贝构造与移动构造的时候,左值会匹配拷贝构造,右值会匹配移动构造。因为:

  1. 左值传递过来的那个引用指向的那个对象还是有空间的。我还要用 ,所以不能直接掠夺,必须深拷贝。
  2. 传递过来的右值就不一样了,右值马上就要销毁了,我可以直接掠夺
  3. 以前没有移动构造时候,即使是将要消亡的右值也要走拷贝构造,浪费资源。

3.4移动语义解决返回值场景问题

回到3.1我们提到的。这个时候使用移动语义可以有效解决这个问题:

原本: vv指向的资源拷贝给ret指向的资源,再销毁vv所在函数的栈帧。

现在:在vv所在的栈帧销毁之前将其指向的资源夺走,交给ret。

调试观察这一过程:

谨慎: 不要轻易给左值move,move(左值)就相当与让左值拥有了可以被夺走数据的权力.

3.5移动语义在传参中的提效

查看STL文档我们发现C++11以后容器的push和insert系列的接口都增加的右值引用版本。

  1. 当实参是一个左值时,容器内部继续调用拷贝构造进行拷贝,将对象拷贝到容器空间中的对象。
  2. 当实参是一个右值,容器内部则调用移动构造,右值对象的资源到容器空间的对象上。

把我们之前模拟实现的bit::list拷贝过来,实现一下支持右值引用参数版本的push_back和insert。

cpp 复制代码
void push_back(T&& x){ insert(end(), move(x));}
 iterator insert(iterator pos, T&& x) {
     Node* cur = pos._node;
     Node* newnode = new Node(move(x));
     Node* prev = cur->_prev;
     prev->_next = newnode;
     newnode->_prev = prev;
     newnode->_next = cur;
     cur->_prev = newnode;
     return iterator(newnode);
 }

其实这里还有一个emplace系列的接口,但是这个涉及可变参数模板,我放在可变参数模版那里讲了。

四,当编译器优化遇上移动构造------是雪中送炭还是锦上添花?

4.1右值对象构造,只有拷贝构造,没有移动构造的场景

图1展示了vs2019 debug环境下编译器对拷贝的优化,左边为不优化的情况下 ,两次拷贝构造,右边为编译器优化的场景下连续步骤中的拷贝合二为一 变为一次拷贝构造。

需要注意的是在vs2019的release和vs2022的debug和release,下面代码优化为非常恐怖,会直接将str对象的构造,str拷贝构造临时对象,临时对象拷贝构造ret对象,合三为一,变为直接构造。要理解这个优化要结合局部对象生命周期和栈帧的角度理解,如图3(4.2的第二张图)所示。

linux下可以将下面代码拷贝到test.cpp文件,编译时用 g++ test.cpp -fno-elide-constructors 的方式关闭构造优化,运行结果可以看到图1左边没有优化的两次拷贝。

4.2右值对象构造,有拷贝构造,也有移动构造的场景

图2展示了vs2019 debug环境下编译器对拷贝的优化,左边为不优化的情况下,两次移动构造 ,右边为编译器优化的场景下连续步骤中的拷贝合二为一变为一次移动构造

需要注意的是在vs2019的release和vs2022的debug和release,下面代码优化为非常恐怖,会直接将str对象的构造,str拷贝构造临时对象,临时对象拷贝构造ret对象,合三为一,变为直接构造 。要理解这个优化要结合局部对象生命周期和栈帧的角度理解,如图2(4.2的第二张图)所示。

linux下可以将下面代码拷贝到test.cpp文件,编译时用 g++ test.cpp -fno-elide-constructors 的方式关闭构造优化,运行结果可以看到图1左边没有优化的两次移动。


4.3右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景

图4左边展示了vs2019 debug和 g++ test.cpp -fno-elide-constructors 关闭优化环境下编译器的处理,一次拷贝构造,一次拷贝赋值。

需要注意的是在vs2019的release和vs2022的debug和release,下面代码会进一步优化,直接构造要返回的临时对象,str本质是临时对象的引用,底层角度用指针实现。运行结果的角度,我们可以看到str的析构是在赋值以后,说明str就是临时对象的别名。

4.4右值对象赋值,既有拷贝构造和拷贝赋值,也有移动构造和移动赋值的场景

图5左边展示了vs2019 debug和 g++ test.cpp -fno-elide-constructors 关闭优化环境下编译器的处理,一次移动构造,一次移动赋值。

需要注意的是在vs2019的release和vs2022的debug和release,下面代码会进一步优化,直接构造要返回的临时对象 ,str本质是临时对象的引用,底层角度用指针实现。运行结果的角度,我们可以看到str的析构是在赋值以后,说明str就是临时对象的别名。

4.5总结结论

看完上文后可能有人会问:那么拷贝构造的和移动构造的这两种情况都是和三位一, 看起来拷贝构造的场景和移动构造的场景没有什么区别啊?

但是,问题就在于:不是所有的公司的编译器都会做这么激进的优化。所以移动构造还是比拷贝构造好的。

好的,我们来做一个总结:

  1. 深拷贝的自定义类型:如vector/string/map...。实现移动构造和移动赋值是有很大的价值的编译器的优化是移动语义的锦上添花

  2. 浅拷贝的自定义类型:如Date/pair<int, int>...。不需要实现移动构造和移动赋值是有意义的但是这里的时候,编译器的优化不再是移动语义的锦上添花

五,C++对左值右值类型进行的分类

  • C++11 以后,进一步对类型进行了划分,右值被划分纯右值(pure value,简称 prvalue)和将亡值(expiring value,简称 xvalue)。
  • 纯右值是指那些字面值常量或求值结果相当于字面值或是一个不具名的临时对象。如:42、true、nullptr,或者类似 str.substr(1, 2)、str1 + str2(传值返回函数调用),或者整形 a、b、a++、a+b 等。
  • 纯右值和将亡值是 C++11 中提出的,C++11 中的纯右值概念划分等价于 C++98 中的右值。
  • 将亡值是指返回右值引用的函数的调用表达式和转换为右值引用的转换函数的调用表达式,如:move(x)、static_cast<X&&>(x)
  • 泛左值(generalized value,简称 glvalue),泛左值包含将亡值和左值。

这里不是很重要了解一下即可,我用一张Excel表格整理出来: 也可以去官方文档中查阅更加细节的东西。

文档传送门cppreference.com-Value categories

六,引用折叠------引用中的引用

6.1什么是引用折叠

定义: 在 C++ 中,引用折叠是指当编译器在模板推导或类型别名等场景下遇到"引用的引用"时,将其合并为单一引用的逻辑规则

6.1.1用typedef写一个引用折叠

C++中不能直接定义引用的引用如 int& && r = i; ,这样写会直接报错,通过模板或 typedef 中的类型操作可以构成引用的引用。例如:

cpp 复制代码
int i=0;
typedef int&& rref;
rref& r1 = i;  // r1 的类型是 int&

通过模板或 typedef 中的类型操作可以构成引用的引用 C++11给出了一个引用折叠的规则 :右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用。(有左则左,全右则右)

cpp 复制代码
int main(){
	typedef int& lref;
	typedef int&& rref;
	
	int n = 0;
	
	lref& r1 = n;  // r1 的类型是 int&
	lref&& r2 = n; // r2 的类型是 int&
	rref& r3 = n;  // r3 的类型是 int&
	rref&& r4 = n; // r4 的类型是 int&&
	return 0;
}

6.1.1模板中的引用折叠

模板亦是如此:

cpp 复制代码
// 由于引用折叠限定,f1实例化以后总是一个左值引用
template
void f1(T& x)
{}
// 由于引用折叠限定,f2实例化后可以是左值引用,也可以是右值引用
template
void f2(T&& x)
{}
  • 像f2这样的函数模板中,T&& x参数看起来是右值引用参数,但是由于引用折叠的规则,他传递左值时就是左值引用,传递右值时就是右值引用 ,有些地方也把这种函数模板的参数叫做万能引用
cpp 复制代码
int n = 0;
// 没有折叠->实例化为void f1(int& x)
f1<int>(n);
f1<int>(0);  // 报错

// 折叠->实例化为void f1(int& x)
f1<int&&>(n);
f1<int&&>(0); // 报错

// 折叠->实例化为void f1(int& x)
f1<int&&>(n);
f1<int&&>(0); // 报错

// 折叠->实例化为void f1(const int& x)
f1<const int&>(n);
f1<const int&>(0);

// 折叠->实例化为void f1(const int& x)
f1<const int&&>(n);

f1<const int&&>(0);

// 没有折叠->实例化为void f2(int&& x)
f2<int>(n);   // 报错
f2<int>(0);

// 折叠->实例化为void f2(int& x)
f2<int&&>(n);
f2<int&&>(0); // 报错

// 折叠->实例化为void f2(int&& x)
f2<int&&>(n); // 报错
f2<int&&>(0);

6.2引用折叠的目的

下面的Function(T&& t)函数模板程序中:

  1. 假设实参是int右值,模板参数T的推导int。
  2. 实参是int左值,模板参数T的推导int&。

再结合引用折叠规则,就实现了实参是左值,实例化出左值引用版本 形参的 Function,实参是右值,实例化出右值引用版本形参的Function。

cpp 复制代码
template<class T>
void Function(T&& t){
    int a = 0;
    T x = t;
    //x++;
    cout << &a << endl;
    cout << &x << endl << endl;
}
int main()
{
    // 10是右值,推导出T为int,模板实例化为void Function(int&& t)
    Function(10);          // 右值

    int a;
    // a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)
    Function(a);           // 左值

    // std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
    Function(std::move(a));   // 右值
		//引用折叠不会丢const属性
    const int b = 8;
    // a是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int& t)
    // 所以Function内部会编译报错,x不能++
    Function(b);              // const 左值

    // std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&& t)
    // 所以Function内部会编译报错,x不能++
    	//右值引用本事是左值属性可以修改,但是现在加上const也不能修改了
    Function(std::move(b));   // const 右值

    return 0;
}

于是不难看出来引用折叠被设计出来的目的是: 减少代码量,不需要我们手动实现出左值引用和右值引用的两种版本的代码。

瞄一眼库里面的万能引用,我们结束这个部分(现在暂时不讲)

七,完美转发------解决右值引用属性异化问题

7.1右值引用属性异化

右值引用属性异化问题:

这是一个非官方的命名,简而言之就是右值引用变量表达式的属性是左值。 当我们想要将这个值继续往下传递的时候,就会发生属性异化。我们举一个例子就明白了:

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }

template<class T>
void Function(T&& t){ Fun(t);}

int main()
{
    // 10是右值,推导出T为int,模板实例化为void Function(int&& t)
    Function(10);// 右值
}
  • Function(T&& t)函数模板程序中,传左值实例化以后是左值引用的Function函数,传右值实例化以后是右值引用的Function函数。

  • 但是变量表达式都是左值属性,也就意味着一个右值被右值引用绑定后,右值引用变量表达式的属性是左值 ,也就是说Function函数中t的属性是左值,那么我们把t传递给下一层函数Fun,那么匹配的都是左值引用版本的Fun函数。

有人说,这太bug了,有没有好的解决方案?于是C++委员会发明了完美转发

7.2完美转发如何解决二次传递问题

完美转发:

完美转发是指在模板编程中,通过 T&& 和 std::forward 的配合 ,将参数原封不动地传递给下一个函数,确保参数的左值/右值属性在传递过程中不丢失。

传递左值,模板推导为int&,传递右值模板推导为int,forward看到int&,返回左值,看到int,返回右值。

完美转发forward本质是一个函数模板 ,他主要还是通过引用折叠的方式实现,下面示例中

  • 传递给Function的实参是右值,T被推导为int,没有折叠,forward内部t被强转为右值引用返回;
  • 传递给Function的实参是左值,T被推导为int&,引用折叠为左值引用,forward内部t被强转为左值引用返回。
cpp 复制代码
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{
    // forward an lvalue as either an lvalue or an rvalue
    return static_cast<_Ty&&>(_Arg);
}
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }
void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
template<class T>
void Function(T&& t){ Fun(forward<T>(t));}
int main()
{
    // 10是右值,推导出T为int,模板实例化为void Function(int&& t)
    Function(10);        // 右值
    int a;
    // a是左值,推导出T为int&,引用折叠,模板实例化为void Function(int& t)
    Function(a);         // 左值
    // std::move(a)是右值,推导出T为int,模板实例化为void Function(int&& t)
    Function(std::move(a));   // 右值
    const int b = 8;
    // a是左值,推导出T为const int&,引用折叠,模板实例化为void Function(const int& t)
    Function(b);              // const 左值
    // std::move(b)右值,推导出T为const int,模板实例化为void Function(const int&& t)
    Function(std::move(b));   // const 右值
    return 0;
}

好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!

相关推荐
Evand J2 小时前
MATLAB批量保存现有绘图窗口的方法,简易方法,直接保存到当前目录,不手动设置
开发语言·matlab·教程
忽而今夏&_&2 小时前
python 刷题最基础的一些
开发语言·python
前端郭德纲2 小时前
JavaScript 原型相关属性详解
开发语言·javascript·原型模式
于先生吖2 小时前
基于 SpringBoot 架构,高性能 JAVA 动漫短剧系统源码
java·开发语言·spring boot
样例过了就是过了2 小时前
LeetCode热题100 跳跃游戏
c++·算法·leetcode·贪心算法·动态规划
chen_ever2 小时前
从网络基础到吃透 Linux 高并发 I/O 核心(epoll+零拷贝 完整版)
linux·网络·c++·后端
无限进步_2 小时前
【C++&string】寻找字符串中第一个唯一字符:两种经典解法详解
开发语言·c++·git·算法·github·哈希算法·visual studio
jwn9993 小时前
Laravel11.x新特性全解析
android·开发语言·php·laravel
feifeigo1233 小时前
航天器交会的分布式模型预测控制(DMPC)MATLAB实现
开发语言·分布式·matlab