1. 左值与右值
1.1 左值与右值的概念
左值:左值本质上是一个表示数据的表达式,常见的如变量名或者解引用后的指针等形式。它具备以下显著特点:
- 可被取地址:能够通过取地址操作符
&
获取其内存地址,这意味着它在内存中有确定的存储位置,是实实在在存在的、具有持久性的数据实体。例如,定义int a = 10;
,变量a
就是左值,我们可以使用&a
获取它的地址。- 可被修改(除
const
修饰的情况外):对于非const
修饰的左值,可以对其进行赋值等修改操作,以改变其存储的值。比如int b = 20; b = 30;
,这里先是定义了左值b
,随后通过赋值操作改变了它的值。
其中左值既可以出现在赋值符号的左边,担当被赋值的对象角色,也可以出现在赋值符号的右边,作为提供数据的一方参与运算。
右值:右值同样是表示数据的表达式,但与左值有所不同,它通常代表那些临时的、不具有持久存储的数据。常见的右值包括字面常量(如整数常量、浮点数常量等)、表达式的返回值以及传值返回的函数的返回值(这里指的是返回对象时返回的是对象拷贝,该拷贝对象作为临时变量构成右值)等。右值具有以下特性:
- 不可被取地址:由于右值大多是临时生成、转瞬即逝的数据,不存在可供长期访问的固定内存地址,所以不能对其进行取地址操作。
- 不可被修改:其临时性决定了它不应该被修改,从语义和实际操作层面都不允许像左值那样进行赋值等改变其值的操作。
右值只能出现在赋值符号的右边,作为提供数据的一方参与赋值等运算,而绝不能出现在赋值符号的左边充当被赋值的对象,否则会导致编译错误。以下是一些常见右值及错误示例的代码展示:
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//错误示例(右值不能出现在赋值符号的左边)
//10 = 1;
//x + y = 1;
//fmin(x, y) = 1;
return 0;
}
1.2 左值引用和右值引用
左值引用:在传统的 C++ 语法中就存在引用这一语法特性,而随着 C++11 的出现新增了右值引用,为了便于区分,便把 C++11 之前的引用称作左值引用。左值引用实质上是一种为已有对象创建别名的机制,通过这种方式,我们能够以引用变量名来操作对应的原对象,就如同直接操作原对象本身一样。左值引用使用 &
符号进行声明,其常见语法形式为 类型 &引用变量名 = 被引用对象;
。以下是相关示例:
int main()
{
//以下的 p、b、c、*p 都是左值
int* p = new int(0);
int b = 1;
const int c = 2;
//以下几个是对上面左值的左值引用
int*& rp = p;
int& rb = b;
const int& rc = c;
int& pvalue = *p;
return 0;
}
需要注意的是,左值引用不能直接引用右值,这是因为右值本身不具备可修改性,而左值引用默认是可以对引用对象进行修改操作的,若允许左值引用右值则会出现权限放大的逻辑冲突问题。
不过,const
左值引用是个例外,它能够引用右值,原因在于 const
左值引用确保了被引用的数据不会被修改,符合右值不可变的特性。例如:
int main()
{
//int& rr1 = 10; // 报错,左值引用不能引用右值
const int& rr1 = 10; // 正确,const 左值引用可以引用右值
return 0;
}
右值引用:右值引用是 C++11 引入的一项新特性,它主要的作用是绑定到右值(即那些临时对象)上,通过 &&
符号来声明,其语法形式为 类型 &&引用变量名 = 右值;
。以下是右值引用的示例代码:
int main()
{
double x = 1.1, y = 2.2;
//以下几个都是常见的右值
10;
x + y;
fmin(x, y);
//以下几个都是对右值的右值引用
int&& rr1 = 10;
double&& rr2 = x + y;
double rr3 = fmin(x, y);
return 0;
}
右值引用有着明确的绑定规则,它只能绑定右值,不能直接绑定左值,这是由其设计初衷决定的,旨在专门处理临时对象相关的操作。然而,右值引用可以引用经过 std::move
函数处理后的左值。
std::move
函数是 C++11 标准提供的一个实用函数,其功能是将一个左值转换为右值引用类型,使得该左值在语义上可被视为右值,进而可以赋值给右值引用。
但需要强调的是,一个左值被 std::move
之后,其内部资源可能会被转移给其他对象,后续再使用这个左值可能会出现问题,所以在实际编程中要谨慎使用 std::move
后的左值。以下是相关示例说明:
int main()
{
double x = 1.1, y = 2.2;
//int&& r1 = a; //右值引用不能引用左值,会报错
int a = 10;
int&& r2 = std::move(a); //右值引用可以引用 move 以后的左值
//以下对引用尝试修改的操作,体现了右值引用绑定规则的约束
//const double&& rr2 = x + y;
//rr2 = 5.5; //报错
return 0;
}
其中std::move
函数的实现原理也并不困难,代码如下:
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
//forward _Arg as movable
return ((typename remove_reference<_Ty>::type&&)_Arg);
}
在上述代码中,_Arg
参数的类型并非简单的右值引用,而是一种被称为“万能引用”的特殊形式。万能引用与右值引用在形式上相似,但万能引用能够根据传入的实际参数类型进行灵活适配,而右值引用需要明确是对应右值类型。
1.3 万能引用
1.3.1 概念及语法形式
万能引用是一种特殊的引用类型,它在函数模板参数中出现,语法形式看起来和右值引用(&&
)一样,但有着不同的语义和行为。当函数模板的参数写成 T&&
形式时(这里 T
是模板类型参数),并且 T
会根据实际传入的实参类型进行推导,这个 T&&
就有可能是万能引用。例如:
template<typename T>
void func(T&& param) {
// 函数体逻辑
}
在上述代码中,param
就是一个万能引用,它会根据调用 func
函数时传入的实际参数是左值还是右值,来推导出不同的类型,进而表现出不同的行为。
1.3.2 推导规则及特性
左值传入时:当传递一个左值给万能引用参数时,模板类型参数 T
会被推导为左值引用类型。例如:
int num = 10;
func(num); // 这里传入的是左值,T会被推导为int&,那么param的类型实际就是int& &&,经过引用折叠规则后就是int&
在C++中存在引用折叠规则,对于 X& &
、X& &&
、X&& &
这些情况,最终都会折叠为 X&
(也就是左值引用),所以这里 param
最终变成了左值引用,可以对传入的左值 num
进行操作,就好像它是直接以左值引用的方式接收参数一样。
右值传入时:当传递一个右值给万能引用参数时,模板类型参数 T
会被推导为非引用类型(也就是值类型)。例如:
func(20); // 这里传入的是右值,T会被推导为int,那么param的类型实际就是int&&,也就是绑定到了右值上
此时 param
就像常规的右值引用一样,绑定到了临时的右值 20
上,可以利用右值引用相关的特性(比如在实现移动语义等场景中发挥作用)。
1.4 完美转发
1.4.1 概念及目的
完美转发的目的是在函数调用过程中,能够将函数参数的所有属性(包括是左值还是右值、const
或非 const
等特性)原封不动地传递给另一个函数(通常是被调用的内层函数),使得内层函数接收到的参数和外层函数接收到的原始参数在这些属性方面完全一致,就好像参数是直接传递给内层函数一样,没有额外的改变或丢失信息。
比如说下列代码,如果并没有经过完美转发最终会输出什么?
#include <iostream>
#include <utility>
template<typename T>
void innerFunc(T&& arg)
{
std::cout << (std::is_lvalue_reference<T>::value ?
"Inner received Lvalue" : "Inner received Rvalue") << std::endl;
}
template<typename T>
void outerFunc(T&& param) {
innerFunc(param);
}
int main() {
int num = 10;
outerFunc(num); // 传递左值
outerFunc(20); // 传递右值
return 0;
}
最后都会输出 Inner received Lvalue
,因为在 C++ 中,无论是左值还是右值作为参数传递给另一个函数时,编译器会将其视为左值进行处理。为了解决这个问题,就需要用到我们的完美转发。
1.4.2 std::forward
std::forward<T>
是C++标准库中用于实现完美转发的一个函数模板,定义在 <utility>
头文件中。它接受一个参数(其类型是根据实际要转发的参数推导出来的模板类型 T
),并根据 T
的类型以及传入的实参情况,准确地将实参按照其原始的左值或右值属性转发出去。例如:
#include <iostream>
#include <utility>
template<typename T>
void innerFunc(T&& arg)
{
std::cout << (std::is_lvalue_reference<T>::value?
"Inner received Lvalue" : "Inner received Rvalue") << std::endl;
}
template<typename T>
void outerFunc(T&& param) {
innerFunc(std::forward<T>(param));
}
int main() {
int num = 10;
outerFunc(num); // 传递左值,通过std::forward在outerFunc中会转发为左值给innerFunc
outerFunc(20); // 传递右值,通过std::forward在outerFunc中会转发为右值给innerFunc
return 0;
}
在上述代码中,outerFunc
函数接收一个万能引用参数 param
,然后在调用 innerFunc
时,使用 std::forward<T>(param)
将 param
转发出去。当在 main
函数中调用 outerFunc
并传入左值 num
时,T
被推导为 int&
,std::forward<int&>(param)
就会把 param
作为左值转发给 innerFunc
;而当传入右值 20
时,T
被推导为 int
,std::forward<int>(param)
会把 param
作为右值转发给 innerFunc
,从而实现了完美转发。
2. 类的新功能
2.1 移动构造与移动赋值
在传统的 C++ 编程中,当我们把一个对象传递给函数或者用一个对象去初始化另一个对象时,通常会调用拷贝构造函数;而当我们对一个对象进行赋值操作时,会调用赋值运算符重载函数。这些拷贝操作在对象包含简单数据类型或者数据量较小时,并不会带来太大的性能问题。
然而,想象一下这样一个场景:我们有一个自定义的类,它内部动态分配了一块很大的内存空间来存储数据,例如一个自定义的字符串类:
class MyString {
private:
char* data;
size_t length;
public:
MyString(const char* str) {
length = strlen(str);
data = new char[length + 1];
strcpy(data, str);
}
// 拷贝构造函数
MyString(const MyString& other) {
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
// 赋值运算符重载
MyString& operator=(const MyString& other) {
if (this!= &other) {
delete[] data;
length = other.length;
data = new char[length + 1];
strcpy(data, other.data);
}
return *this;
}
~MyString() {
delete[] data;
}
};
当我们进行如下操作时:
MyString str1("Hello");
MyString str2 = str1; // 调用拷贝构造函数
MyString str3("World");
str3 = str1; // 调用赋值运算符重载函数
无论是拷贝构造函数还是赋值运算符重载函数,它们都会执行深拷贝操作,也就是重新分配内存,然后把原对象中的数据逐个复制到新对象对应的内存空间中。
这对于大型对象来说,开销是非常大的,而且很多时候,我们可能并不需要真正的拷贝,比如当 str1
这个对象在后续代码中不再使用时,我们其实只是想把它所管理的资源“转移”给其他对象,而不是复制一份,这就是移动构造函数和移动赋值重载要解决的问题。
当然现代编译器也针对这种"不必要"的拷贝进行了优化,进一步提升我们的代码效率,这里就不在详细阐述。
2.1.1 移动构造
移动构造函数是一种特殊的构造函数,它的参数是右值引用类型(使用 &&
来声明),用于将一个临时对象(右值)的资源“移动”到正在构造的新对象中,避免了不必要的深拷贝,提高了效率。其语法形式大致如下:
class ClassName {
public:
ClassName(ClassName&& other) {
// 移动资源的逻辑
}
};
例如,我们对前面的 MyString
类添加移动构造函数:
class MyString {
private:
char* data;
size_t length;
public:
MyString(const char* str) {
// 构造函数实现不变,省略部分代码
}
// 拷贝构造函数
MyString(const MyString& other) {
// 拷贝构造函数实现不变,省略部分代码
}
// 移动构造函数
MyString(MyString&& other) : data(other.data), length(other.length) {
other.data = nullptr;
other.length = 0;
}
// 赋值运算符重载
MyString& operator=(const MyString& other) {
// 赋值运算符重载实现不变,省略部分代码
}
~MyString() {
// 析构函数实现不变,省略部分代码
}
};
在这个移动构造函数中,我们直接将传入的右值对象 other
的资源指针 data
和长度 length
拿过来,赋值给当前正在构造的对象,然后把 other
对象的资源指针置为 nullptr
,长度设为 0
,这样就完成了资源的“移动”,原右值对象后续被销毁时也不会错误地释放已经“移走”的资源了。
2.1.2 移动赋值
移动赋值重载函数用于处理已经存在的对象接收另一个对象(通常是右值)资源的情况,它同样利用右值引用参数来实现高效的资源转移,语法形式如下:
class ClassName {
public:
ClassName& operator=(ClassName&& other) noexcept {
// 移动资源的逻辑
return *this;
}
};
继续完善我们的 MyString
类的移动赋值重载函数示例:
class MyString {
private:
char* data;
size_t length;
public:
MyString(const char* str) {
// 构造函数实现不变,省略部分代码
}
// 拷贝构造函数
MyString(const MyString& other) {
// 拷贝构造函数实现省略部分代码
}
// 移动构造函数
MyString(MyString&& other) : data(other.data), length(other.length) {
other.data = nullptr;
other.length = 0;
}
// 移动赋值重载
MyString& operator=(MyString&& other) {
if (this!= &other) {
delete[] data;
data = other.data;
length = other.length;
other.data = nullptr;
other.length = 0;
}
return *this;
}
~MyString() {
// 析构函数实现不变,省略部分代码
}
};
在移动赋值重载函数中,我们先释放当前对象已有的资源(通过 delete[] data
),然后将传入右值对象 other
的资源转移过来,最后把 other
对象的资源相关属性重置,保证其处于一个合理的“空”状态,避免资源的重复释放等问题。
最后还需要强调的一点就是:移动构造与移动赋值与其他六个默认成员函数不同的是,如果我们不手动写出,编译器并不直接默认生成,还需要遵循某些特定的条件:
- 移动构造函数的生成条件:没有自己实现移动构造函数,并且没有自己实现析构函数、拷贝构造函数和拷贝赋值函数。
- 移动赋值重载函数的生成条件:没有自己实现移动赋值重载函数,并且没有自己实现析构函数、拷贝构造函数和拷贝赋值函数。
2.2 类成员变量初始化
在 C++11 之前,我们在定义类的时候,对于成员变量的初始化往往有着相对固定的方式,而 C++11 带来了更加便捷和灵活的特性,允许我们在类成员变量声明的时候直接给定缺省值,就像下面这个简单的 Person
类示例一样:
class Person
{
public:
//...
private:
//非静态成员变量,可以在成员声明时给缺省值
std::string _name = "张三"; //姓名
int _age = 20; //年龄
static int _n; //静态成员变量不能给缺省值
};
2.3 default 与 delete
2.3.1 default
在 C++ 中,对于类的一些特殊成员函数,比如默认构造函数、拷贝构造函数、拷贝赋值函数、移动构造函数以及移动赋值函数等,如果我们没有显式地定义它们,编译器在满足一定条件下会自动为我们生成默认版本。然而,有时候我们可能已经手动定义了部分构造函数或者其他相关函数,导致编译器不再生成某个我们后续又需要的默认函数版本了。这时,default
关键字就派上用场了,它允许我们显式地要求编译器生成这些函数的默认版本。
使用 default
关键字的语法非常简单,就是在函数声明后面加上 = default
。例如,我们定义了一个简单的类,手动写了一个带参数的构造函数,但又希望编译器生成默认构造函数的情况:
class MyClass {
private:
int num;
public:
// 自定义的带参数构造函数
MyClass(int n) : num(n) {}
// 使用default关键字让编译器生成默认构造函数
MyClass() = default;
};
在上述代码中,通过 MyClass() = default;
这一语句,我们明确告知编译器生成默认的无参构造函数,它的行为和编译器自动生成的默认构造函数一样,会对类中的成员进行默认初始化(对于内置类型,不进行初始化;对于具有默认构造函数的自定义类型,调用其默认构造函数)。
除了构造函数外,像拷贝构造函数、拷贝赋值函数等也可以使用 default
关键字来恢复编译器生成默认版本,例如:
class AnotherClass {
private:
std::string str;
public:
AnotherClass(const std::string& s) : str(s) {}
// 让编译器生成默认的拷贝构造函数
AnotherClass(const AnotherClass&) = default;
// 让编译器生成默认的拷贝赋值函数
AnotherClass& operator=(const AnotherClass&) = default;
};
这样可以方便我们在需要的时候,灵活地利用编译器提供的默认函数行为,同时也可以根据实际情况自定义其他相关函数,使得类的设计更加符合需求。
2.3.2 delete
delete
关键字主要用于禁止编译器自动生成某些函数或者禁止某些类型的函数调用、对象创建等操作。在实际编程中,有些情况下我们可能不希望类的对象之间进行拷贝操作,或者不想让某个类被实例化等,delete
关键字就能帮助我们精准地控制这些行为,从而避免因误操作或者不合理的使用而引发的错误。
比如,我们想要禁止对某个类的拷贝构造函数和拷贝赋值函数的调用,可以这样写:
class NoCopyClass {
public:
NoCopyClass() {}
// 使用delete关键字禁止拷贝构造函数的调用
NoCopyClass(const NoCopyClass&) = delete;
// 使用delete关键字禁止拷贝赋值函数的调用
NoCopyClass& operator=(const NoCopyClass&) = delete;
};
在上述代码中,定义了 NoCopyClass
类,通过 = delete
的方式明确告知编译器,不允许调用拷贝构造函数和拷贝赋值函数,这样如果在代码中出现类似 NoCopyClass obj1; NoCopyClass obj2 = obj1;
(调用拷贝构造函数)或者 NoCopyClass obj3; obj3 = obj1;
(调用拷贝赋值函数)这样的语句,编译器就会报错,提示相应的函数已被删除,不能使用。
有时候我们还可以利用 delete
关键字来禁止基于某些特定参数类型创建对象,例如:
class OnlyIntClass {
public:
// 正常的构造函数,接受int类型参数
OnlyIntClass(int n) {}
// 使用delete关键字禁止使用double类型参数创建对象
OnlyIntClass(double) = delete;
};
在这个示例中,OnlyIntClass
类希望只允许使用 int
类型参数来构造对象,通过将接受 double
类型参数的构造函数标记为 = delete
,当出现 OnlyIntClass obj(3.14);
这样的语句时,编译器就会报错,阻止使用 double
类型创建该类的对象。
2.4 final 与 override
2.4.1 final
final
关键字在 C++11 中有两个主要的应用场景,一是用于修饰类,表示该类不能被继承;二是用于修饰虚函数,表示该虚函数在派生类中不能被重写。这样可以帮助我们更好地控制类的继承体系以及函数的多态行为,增强代码的稳定性和可维护性。
当我们希望某个类是最终形态,不希望其他类继承自它时,可以使用 final
关键字来修饰这个类,例如:
class FinalClass final {
// 类的成员定义等内容
};
class TryToInherit : public FinalClass { // 这里会导致编译错误,因为FinalClass被标记为final,不能被继承
// 试图继承FinalClass的代码
};
在上述代码中,FinalClass
类被声明为 final
,所以当 TryToInherit
类试图继承它时,编译器会报错,阻止这种继承行为,保证了 FinalClass
的独立性和完整性,避免因不合理的继承导致的代码逻辑混乱。
对于虚函数,如果我们希望在某个类中定义的虚函数在派生类中不能再被重写,就可以使用 final
关键字来修饰这个虚函数,例如:
class Base {
public:
virtual void func() final {
std::cout << "Base::func" << std::endl;
}
};
class Derived : public Base {
public:
// 这里会导致编译错误,因为Base类中的func函数被标记为final,不能在Derived类中重写
void func() {
std::cout << "Derived::func" << std::endl;
}
};
在这个示例中,Base
类中的 func
虚函数被标记为 final
,那么在 Derived
类中想要重写这个函数就会被编译器阻止,确保了虚函数调用的预期行为,防止派生类意外地改变了函数的实现逻辑,维护了多态机制下函数调用的稳定性。
2.4.2 override
在 C++ 的多态编程中,派生类常常需要重写基类中的虚函数来实现特定的行为。然而,有时候可能会因为函数签名不一致(比如函数参数类型、个数或者返回值类型等细微差异)导致出现重写错误,而这种错误可能比较隐蔽,不容易在编译阶段被发现。
override
关键字就是为了解决这个问题而引入的,它用于显式地告诉编译器,当前在派生类中定义的函数是要重写基类中的某个虚函数,如果函数签名不一致,编译器就会报错,从而帮助我们及时发现并纠正重写相关的错误。
当派生类重写基类的虚函数时,在函数声明后面加上 override
关键字,例如:
class Base {
public:
virtual void printInfo() {
std::cout << "Base class printInfo" << std::endl;
}
};
class Derived : public Base {
public:
// 使用override关键字明确表示重写Base类中的printInfo虚函数
void printInfo() override {
std::cout << "Derived class printInfo" << std::endl;
}
};
在上述代码中,Derived
类中的 printInfo
函数使用了 override
关键字,这样编译器会检查它是否真的正确重写了 Base
类中的 printInfo
虚函数。如果函数签名不一致(比如不小心把参数类型改了等情况),编译器就会提示错误,提醒我们修复重写的函数使其符合重写规则,保证了多态机制下函数重写的正确性和可靠性。