条款十六:确保const成员函数线程安全
C++ const成员函数表示该函数不会修改对象的状态。如果这些函数内部使用了可变(mutable)成员变量来缓存计算结果,那么它们在多线程环境中可能不是线程安全的。
例子中的Polynomial类有一个roots()方法,它是一个const成员函数,但会更新mutable成员rootsAreValid和rootVals来缓存根值。在没有同步的情况下,这些代码会有不同的线程读写相同的内存,会产生数据竞争。root
成员函数虽然被声明为const
,但不是线程安全的。
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
if (!rootsAreValid) { //如果缓存不可用
… //计算根
//用rootVals存储它们
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false }; //初始化器(initializer)的
mutable RootsType rootVals{}; //更多信息请查看条款7
};
const成员函数应当是线程安全的,以避免数据竞争。
解决方案
使用互斥锁(mutex)进行同步
通过在roots()方法中使用std::lock_guard<std::mutex>,可以确保在访问或更新缓存时只有一个线程能够执行相关代码段。使用std::mutex可以保护共享资源,防止多个线程同时修改相同的内存位置。
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const{
std::lock_guard<std::mutex> g(m); //锁定互斥量
if (!rootsAreValid) {//如果缓存无效
//计算/存储根值
rootsAreValid = true;
}
return rootVals;//返回缓存的根值
}
private:
mutable std::mutex m; // 互斥量
mutable bool rootsAreValid { false };
mutable RootsType rootVals {};
};
使用原子变量(Atomic)
对于仅涉及单个变量或内存位置的操作,可以使用std::atomic来替代互斥量,以减少同步开销。
std::atomic类型提供了一种更轻量级的方式来保证某些操作的原子性,适用于单个变量或内存位置的操作。但是,当需要对多个变量作为单元操作时,仍需使用互斥量,因为std::atomic无法保证跨多个变量的操作的原子性。
结论
const成员函数应当设计为线程安全,在预期会被多个线程并发调用的情况下。选择合适的同步机制(如std::mutex或std::atomic),取决于具体需求和性能考虑。当需要保证多个变量或内存位置的一致性时,应该使用互斥量而不是原子变量。
条款十七:理解特殊成员函数的生成
特殊成员函数是指C++自己生成的函数。C++98有四个:默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符。当然在这里有些细则要注意。默认构造函数仅在类完全没有构造函数的时候才生成。(防止编译器为某个类生成构造函数,但是你希望那个构造函数有参数)生成的特殊成员函数是隐式public且inline
,它们是非虚的,除非相关函数是在派生类中的析构函数,派生类继承了有虚析构函数的基类。在这种情况下,编译器为派生类生成的析构函数是虚的。C++11特殊成员函数新增移动构造函数和移动赋值运算符。
class Widget {
public:
…
Widget(Widget&& rhs); //移动构造函数
Widget& operator=(Widget&& rhs); //移动赋值运算符
…
};
移动操作仅在需要的时候生成,如果生成了,就会对类的non-static数据成员执行逐成员的移动。
移动构造函数根据rhs
参数里面对应的成员移动构造出新non-static部分,移动赋值运算符根据参数里面对应的non-static成员移动赋值。移动构造函数也移动构造基类部分(如果有的话),移动赋值运算符也是移动赋值基类部分。当对一个数据成员或者基类使用移动构造或者移动赋值时,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。
逐成员移动的核心是对对象使用std::move
,然后函数决议时会选择执行移动还是拷贝操作。
关于拷贝和移动的一些规则
(1)拷贝操作(拷贝构造函数与拷贝赋值运算符)
如果声明了一个拷贝构造函数,但没有声明拷贝赋值运算符,编译器会自动生成拷贝赋值运算符;反之亦然。两个拷贝操作是独立的,一个的存在不会阻止另一个的自动生成。如果类没有任何用户定义的拷贝操作,编译器将为类生成默认的拷贝构造函数和拷贝赋值运算符。默认情况下,这两个函数都会执行逐成员的浅拷贝。
(2)移动操作(移动构造函数与移动赋值运算符)
移动构造函数和移动赋值运算符不是独立的。一旦显式声明了其中一个,编译器就不会再生成另一个。这是因为如果需要定制移动构造函数的行为,很可能移动赋值运算符也需要类似的处理。如果类没有任何用户定义的拷贝或移动操作,并且没有用户定义的析构函数,那么编译器可以自动生成移动构造函数和移动赋值运算符。默认情况下,这两个函数都会执行逐成员的移动操作,即调用每个成员的移动构造函数或移动赋值运算符。
(3)拷贝操作与移动操作的关系
拷贝抑制移动:如果一个类显式声明了拷贝构造函数或拷贝赋值运算符中的任何一个,那么编译器将不会自动生成移动构造函数和移动赋值运算符。如果用户定义了拷贝操作,逐成员拷贝不合适,那么逐成员移动也可能不合适。
(4)移动抑制拷贝
如果一个类显式声明了移动构造函数或移动赋值运算符中的任何一个,那么编译器将不会自动生成拷贝构造函数和拷贝赋值运算符。相反,它会将这些拷贝操作标记为delete,以防止通过拷贝进行对象的复制。这确保了如果逐成员移动不适合这个类,那么逐成员拷贝同样也不适合。
(5)对C++98代码的影响
C++11引入了移动语义,但是为了保持向后兼容性,C++98的代码不会因为新规则而被破坏。C++98标准中没有移动操作的概念,因此老代码不会受到影响。如果要让旧的C++98代码支持移动语义,需要使用C++11标准,并在类中添加相应的移动构造函数和移动赋值运算符。这样做之后,类就必须遵循C++11的特殊成员函数生成规则。
示例1: 使用 = default 显式声明特殊成员函数
class Widget {
public:
~Widget(); // 用户声明的析构函数
Widget(const Widget&) = default; // 默认拷贝构造函数
Widget& operator=(const Widget&) = default; // 默认拷贝赋值运算符
};
通过使用= default,明确表示希望使用编译器提供的默认实现。防止因添加新的功能(如日志记录)而无意间影响到特殊成员函数的自动生成,从而避免性能问题或其他意外行为。
示例2: 多态基类的特殊成员函数
class Base {
public:
virtual ~Base() = default; // 虚析构函数
Base(Base&&) = default; // 移动构造函数
Base& operator=(Base&&) = default; // 移动赋值运算符
Base(const Base&) = default; // 拷贝构造函数
Base& operator=(const Base&) = default; // 拷贝赋值运算符
};
确保多态基类支持拷贝和移动语义。提供了完整的拷贝和移动语义支持,同时保持虚析构函数以正确处理派生类对象的删除。
示例3: 日志记录导致的性能问题
class StringTable {
public:
StringTable() { makeLogEntry("Creating StringTable object"); }
~StringTable() { makeLogEntry("Destroying StringTable object"); }
private:
std::map<int, std::string> values;
};
添加了用户定义的析构函数后,编译器不再生成移动构造函数和移动赋值运算符。移动操作退化为拷贝操作,导致性能下降,因为std::map的拷贝比移动慢得多。
解决方案:显式声明并使用= default来恢复移动操作。
class StringTable {
public:
StringTable() { makeLogEntry("Creating StringTable object"); }
~StringTable() { makeLogEntry("Destroying StringTable object"); }
StringTable(StringTable&&) = default; // 移动构造函数
StringTable& operator=(StringTable&&) = default; // 移动赋值运算符
private:
std::map<int, std::string> values;
};
总结:C++11对于特殊成员函数处理的规则如下:
- 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
- 析构函数:基本上和C++98相同,稍微不同的是现在析构默认
noexcept
(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。 - 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
- 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
- 移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。