美文网首页Effective Modern C++
【Effective Modern C++(3)】转向现代C++

【Effective Modern C++(3)】转向现代C++

作者: downdemo | 来源:发表于2018-11-30 10:36 被阅读31次

07 创建对象时注意区分()和{}

  • 指定初始化值的方式如下
int x(0); // 初始化值在小括号中
int y = 0; // 初始化值在等号后
int z{ 0 }; // 初始化值在大括号中
  • 使用等号加大括号通常会按只有大括号的语法处理,后续讨论将忽略这种用法
int z = { 0 };
  • 使用等号会让新手误以为会发生赋值,对于内置类型来说,初始化和赋值的区别只是学术争议,但对于类类型则不同
Widget w1; // 默认构造函数
Widget w2 = w1; // 拷贝而非赋值
w1 = w2; // 拷贝而非赋值
  • 尽管初始化语法很多,但C++98却无法进行一些想要的初始化,比如指定STL容器在创建时有一个特定集合值
  • 为了解决众多初始化的困扰以及部分初始化场景不被覆盖的问题,C++11引入了统一初始化(uniform initialization),也可以叫大括号初始化(braced initialization)。使用大括号指定容器的初始内容很简单
std::vector<int> v{ 1, 3, 5 }; // v的初始内容为1、3、5
  • 大括号同样能为non-static数据成员指定默认初始化值,也可以用=,但不能用小括号
class Widget {
    …
private:
    int x{ 0 }; // x的默认值是0
    int y = 0; // y的默认值是0
    int z(0); // 错误
};
  • 但不可复制的对象(如std::atomic类型对象)可以用大括号和小括号初始化,但不能用=
std::atomic<int> ai1{ 0 }; // OK
std::atomic<int> ai2(0); // OK
std::atomic<int> ai3 = 0; // 错误
  • 可见只有大括号初始化适用所有场合
  • 大括号初始化禁止内置类型的隐式窄化转换(implicit narrowing conversions),而小括号和=不会
double x, y, z;
…
int sum1{ x + y + z }; // 错误:double和不允许表达为int
int sum2(x + y + z); // OK:表达式的值被截断为int
int sum3 = x + y + z; // 同上
  • 大括号初始化的另一个值得一提的特征是,它对C++最令人苦恼的解析语法(most vexing parse)免疫。C++规定任何能解析为声明的都要解析为声明,这会带来副作用,比如默认构造一个对象却声明了一个函数
Widget w1(10); // 调用构造函数
Widget w2(); // most vexing parse,声明一个函数
Widget w3{}; // 调用构造函数,而没有上面的问题
  • 大括号初始化的缺陷是有时会出现意外行为,这源于大括号初始化值、std::initializer_list和构造函数重载解析之间的纠结关系,比如item2提及的auto问题
  • 调用构造函数时,只要不涉及std::initializer_list类型形参,大括号和小括号就没有区别
class Widget {
public:
    Widget(int i, bool b); // 没有std::initializer_list类型参数
    Widget(int i, double d); // 没有std::initializer_list类型参数
    …
};

Widget w1(10, true); // 调用第一个构造函数
Widget w2{10, true}; // 同上
Widget w3(10, 5.0); // 调用第二个构造函数
Widget w4{10, 5.0}; // 同上
  • 如果构造函数有std::initializer_list类型参数,大括号初始化就会优先选择带std::initializer_list参数的版本
class Widget {
public:
    Widget(int i, bool b); // 没有std::initializer_list类型参数
    Widget(int i, double d); // 没有std::initializer_list类型参数
    Widget(std::initializer_list<long double> il); // 新增的构造函数
    …
};

Widget w1(10, true); // 调用第一个构造函数
Widget w2{10, true}; // 调用第三个构造函数,10和true转为long double
Widget w3(10, 5.0); // 调用第二个构造函数
Widget w4{10, 5.0}; // 调用第三个构造函数,10和5.0转为long double
  • 即使正常拷贝或移动的构造函数也可能被带std::initializer_list参数的构造函数劫持
class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<long double> il);
    operator float() const; // 强制转为float
    …
};

Widget w5(w4); // 拷贝构造
Widget w6{w4}; // std::initializer_list版本拷贝构造(w4转为float,float再转为long double)
Widget w7(std::move(w4)); // 移动构造
Widget w8{std::move(w4)}; // std::initializer_list版本移动构造(处理同w4)
  • 即使std::initializer_list版本构造函数的调用错误,大括号初始化也会优先对其调用
class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<bool> il);
    …
}; 

Widget w{10, 5.0}; // 错误:需要窄化转换为bool
  • 只有无法把大括号中的初始化值转为std::initializer_list模板中的类型时,编译器才会选择其他重载版本
class Widget {
public:
    Widget(int i, bool b);
    Widget(int i, double d);
    Widget(std::initializer_list<std::string> il);
    …
}; 

Widget w1{10, true}; // int和bool无法转为std::string,转而调用第一个构造函数
Widget w2{10, 5.0}; // int和bool无法转为std::string,转而调用第二个构造函数
  • 如果用空大括号来构造对象,即使有std::initializer_list版本构造函数,也会调用默认构造函数
class Widget {
public:
    Widget();
    Widget(std::initializer_list<int> il);
};
Widget w1; // 调用默认构造函数
Widget w2{}; // 同上
Widget w3(); // most vexing parse,声明一个函数
  • 如果希望用空的std::initializer_list作为参数,把空大括号作为参数
Widget w4({}); // 调用std::initializer_list版本构造函数
Widget w5{{}}; // 同上
  • 你可能会觉得这些和日常设计没什么关系,但实际影响很大,比如std::vector有一个形参中无std::initializer_list的构造函数,它允许创建指定个数的某元素
std::vector<int> v1(10, 20); // 使用non-std::initializer_list构造函数,初始化内容为10个20
std::vector<int> v2{10, 20}; // 使用std::initializer_list版本构造函数,初始化内容为10和20
  • 作为类设计者最好把构造函数设计成无论用大括号还是小括号都不影响调用的重载版本,std::vector的这种设计是败笔,应该从中吸取教训
  • 对于开发模板来说,创建对象时选择大括号还是小括号非常头疼,因为两个选择都不好。比如想用任意数量实参创建一个任意类型对象
template<typename T, typename... Ts>
void doSomeWork(Ts&&... params)
{
    用params创建T类型局部对象 // 下面有两种实现方式
    …
}
  • 伪代码换成实际代码的两种方式
T localObject(std::forward<Ts>(params)...); // 使用小括号
T localObject{std::forward<Ts>(params)...}; // 使用大括号
  • 对于如下调用
std::vector<int> v;
…
doSomeWork<std::vector<int>>(10, 20);
  • 如果使用的是小括号,结果会得到包含10个元素的std::vector,使用大括号则是2个元素,而正确的预期结果只有调用者知道,模板的作者并不知道应该怎么做。这正是std::make_shared和std::make_unique面临的问题,而这些函数的解决办法是在内部使用小括号,并把这个决定写进文档来广而告之,作为其接口的组成部分

08 使用nullptr替代0和NULL

  • 字面值0本质是int而非指针,只有在使用指针的语境中发现0才会解释为空指针
  • NULL实际效果和0差不多,实际实现没有统一标准,可以是非int整型,也不具有指针类型
void f(bool) { cout << "1"; }
void f(int) { cout << "2"; }
void f(void*) { cout << "3"; }

int main()
{
    f(0); // 2
    f(NULL); // 2,也可能不通过编译
    f(nullptr); // 3
}
  • nullptr的优点在于不具有整型类型,不过它也不具备指针类型,nullptr的实际类型是std::nullptr_t,std::nullptr_t可以隐式转换为任何原始指针类型
  • nullptr除了可以避免意外的重载解析,还能提升代码清晰度,尤其是涉及auto变量时
auto result = findRecord( /* arguments */ );
if (result == 0) { ... }
  • 如果不知道findRecord返回类型,result是整型还是指针也无法确定,使用nullptr则可以避免这个问题
  • nullptr在模板中的表现最为突出。假设有一些只在适当的mutex加锁时才调用的函数,每个函数的参数是不同类型的指针
// 仅当mutex加锁时才调用这些函数
int f1(std::shared_ptr<Widget> spw);
double f2(std::unique_ptr<Widget> upw);
bool f3(Widget* pw);
  • 调用这些函数并传入空指针
std::mutex f1m, f2m, f3m;
using MuxGuard = std::lock_guard<std::mutex>;
…
{
    MuxGuard g(f1m); // lock mutex for f1
    auto result = f1(0);
} // unlock mutex
…
{
    MuxGuard g(f2m); // lock mutex for f2
    auto result = f2(NULL);
} // unlock mutex
…
{
    MuxGuard g(f3m); // lock mutex for f3
    auto result = f3(nullptr);
} // unlock mutex
  • 前两个调用未使用nullptr略有遗憾,但这些代码仍能运行。更遗憾的是重复加锁、调用函数、解锁三个步骤的冗余代码,因此用模板来避免重复调用
template<typename FuncType,
    typename MuxType,
    typename PtrType>
auto lockAndCall(FuncType func,
    MuxType& mutex,
    PtrType ptr) -> decltype(func(ptr)) // C++14可以简化为decltype(auto)
{
    MuxGuard g(mutex);
    return func(ptr);
}
  • 模板的调用者可能写出下面的代码
auto result1 = lockAndCall(f1, f1m, 0); // 错误
auto result2 = lockAndCall(f2, f2m, NULL); // 错误
auto result3 = lockAndCall(f3, f3m, nullptr); // OK
  • 第一个调用的问题是,0被推断为int,ptr被定为int,因此函数体中的func调用时传入的也是int,这与期望的std::shared_ptr<Widget>不符。对于NULL类似,ptr会被推断为某种整型,与f2的期望参数类型不同。nullptr则没有问题,它可以转为f3需要的Widget*

09 使用别名声明替代typedef

  • 别名声明和typedef完成的工作一样,但别名声明在处理函数指针类型时更容易理解
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::string&);
  • 使用别名声明的complling reason是模板,别名声明可以模板化而typedef不行,这涉及别名模板(alias template)技术。别名声明的机制在C++11之前必须用嵌套在struct中的typedef才能表达,比如定义一个表达链表的同义词,它使用一个MyAlloc自定义分配器
template<typename T> // MyAllocList<T>是std::list<T, MyAlloc<T>>的同义词
using MyAllocList = std::list<T, MyAlloc<T>>;
MyAllocList<Widget> lw;

// 如果要用typedef
template<typename T> // MyAllocList<T>::type是std::list<T, MyAlloc<T>>的同义词
struct MyAllocList {
    typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;
  • 更糟的是,如果要在模板中typedef创建一个元素类型类型由模板参数指定的链表,还要给typedef的名字加一个typename前缀
template<typename T>
class Widget { // Widget<T>包含一个MyAllocList<T>类型数据成员
private:
    typename MyAllocList<T>::type list;
    …
};
  • 使用别名模板则不需要也不允许typename,同时也不需要::type后缀
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

template<typename T>
class Widget {
private:
    MyAllocList<T> list;
    …
};
  • C++11在<type_traits>中给出了一整套用于type traits的模板
std::remove_const<T>::type // 由const T生成T
std::remove_reference<T>::type // 由T&或T&&生成T
std::add_lvalue_reference<T>::type // 由T生成T&
  • 而这些变换都用::type结尾,因此在模板内使用时还要加上typename前缀,这个用法没能跟上时代的原因是C++11的type traits是用嵌套在struct中的typedef实现的。C++14中给C++11的所有变换都加上了对应的别名模板,每个C++11的std::transformation<T>::type在C++14中都有对应的名为std::transformation_t的别名模板
std::remove_const<T>::type // C++11: const T → T
std::remove_const_t<T> // C++14 equivalent
std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 equivalent
std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 equivalent
  • 这些C++11的用法在C++14中仍然有效,但没有继续用的理由。即使没有C++14,自己写别名模板也不是难事
template <class T>
using remove_const_t = typename remove_const<T>::type;
template <class T>
using remove_reference_t = typename remove_reference<T>::type;
template <class T>
using add_lvalue_reference_t =
typename add_lvalue_reference<T>::type;

10 使用限定作用域enum替代非限定作用域enum

  • 一个通用规则是,在大括号中声明一个名称,则该名称的可见性就被限定在大括号的作用域内,但这个规则对C++98的枚举类型中的枚举成员例外。枚举成员属于包含这个枚举类型的作用域,因此在作用域内不能有其他同名实例,这种枚举被称为不限作用域的枚举类型
enum Color { black, white, red };
auto white = false; // 错误:white已在作用域内声明过
  • C++11引入了限定作用域的枚举类型,用enum class关键字表示,因此也可以称其为枚举类
enum class Color { black, white, red };
auto white = false; // OK
Color c = white; // 错误:作用域中没有名为white的枚举量
Color c = Color::white; // OK
auto c = Color::white; // OK
  • 除了能降低命名空间污染外,enum class还有一个优势是不会进行隐式转换。unscoped enum允许隐式转换到整型,而由此可以进一步转换到浮点型,这将导致能写出下面这种奇怪的代码
enum Color { black, white, red }; // unscoped enum

std::vector<std::size_t> primeFactors(std::size_t x); // 返回x的质因数的函数

Color c = red;
…
if (c < 14.5) { // 将Color类型和double比较
    auto factors = primeFactors(c);
    …
}
  • 而enum class禁止隐式转换则可以避免这个问题
enum class Color { black, white, red };

std::vector<std::size_t> primeFactors(std::size_t x); // 返回x的质因数的函数

Color c = Color::red; // 现在要指定范围修饰词
…
if (c < 14.5) { // 错误:不能将Color类型和double比较
    auto factors = primeFactors(c); // 错误:不能将Color类型转为std::size_t
    …
}
  • 如果想将enum class转为其他类型,使用强制转换即可
if (static_cast<double>(c) < 14.5) {
    auto factors = primeFactors(static_cast<std::size_t>(c)); // 可能不合法
    …
}
  • enum class的第三个好处是可以前置声明,但C++11中unscoped enum也可以前置声明
enum Color; // C++11之前错误
enum class Color; // OK
  • C++11之前不能前置声明unscoped enum的原因与编译器有关。编译器会为枚举类型选择一个整数类型作为底层类型,比如对于
enum Color { black, white, red };
  • 编译器会选择char作为底层类型,因为只需要表示三个值。为了节约内存,编译器会选择足够容纳枚举成员取值的最小类型,比如对于
enum Status {
    good = 0,
    failed = 1,
    incomplete = 100,
    corrupt = 200,
    indeterminate = 0xFFFFFFFF
};
  • 编译器就会选择比char更大的类型。有时编译器会用空间换时间,这样就可能会不选择最小整数类型,但它们需要具备优化空间的能力。为了支持这种设计,C++98就只支持了列出所有枚举成员的定义,而不允许声明,这样编译器就可以在枚举类型被使用前来逐个确定底层类型
  • 缺少前置声明的能力会造成的最大弊端之一就是编译依赖型,比如在enum中加一个枚举成员,整个系统可能就要重新编译,即使只有一个地方用到这个新成员
enum Status {
    good = 0,
    failed = 1,
    incomplete = 100,
    corrupt = 200,
    audited = 500, // 添加一个成员
    indeterminate = 0xFFFFFFFF
};
  • C++11的枚举声明则可以解决这个问题,比如对于
enum class C;
void f(C c);
  • 如果在头文件中包含这些声明,在修改enum class的定义时也不需要重新编译。甚至如果修改了enum class而函数的行为未受影响,则函数的实现也不需要重新编译
  • C++11支持枚举前置声明的原因很简单:enum class的底层类型是已知的,默认为int
  • C++11中可以指定枚举的底层类型,如果不指定,enum class默认为int,unscoped enum则不存在默认类型
enum class Status: std::uint32_t; // 指定类型为<cstdint>中的std::unit32_t
enum Color: std::uint8_t; // 指定unscoped enum的底层类型为std::unit8_t
// 也可以在定义中指定
enum class Status: std::uint32_t {
    good = 0,
    failed = 1,
    incomplete = 100,
    corrupt = 200,
    audited = 500,
    indeterminate = 0xFFFFFFFF
};
  • 但unscoped enum在一种情况下是有用的,即需要引用std::tuple元素时
// tuple表示用户信息,元素分别用来表示名字、邮箱、声望值
using UserInfo = std::tuple<std::string, std::string, std::size_t>;
UserInfo uInfo;
…
auto val = std::get<1>(uInfo); // 获取第1个值(从0开始)
  • 如果用unscoped enum来表示,则代码会清晰得多,而不需要记住某个数对应的内容
enum UserInfoFields { uiName, uiEmail, uiReputation };
using UserInfo = std::tuple<std::string, std::string, std::size_t>;
UserInfo uInfo;
…
auto val = std::get<uiEmail>(uInfo);
  • std::get要求的类型是std::size_t,unscoped enum允许这种隐式转换。如果用enum class,则需要强制转换,过于啰嗦
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; // as before
…
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);
  • 想要避免这种冗余,就要写一个接受枚举参数而返回对应的std::size_t类型值的函数,事实上这个工作并不那么简单。std::get是一个模板,因此这个函数必须在编译器计算出结果,这就必须使用constexpr。为了配合任何枚举类型,则必须泛化为constexpr函数模板,这样返回值也要泛化,不能返回std::size_t,而需要返回枚举的底层类型,这个类型能用type traits std::underlying_type获取。最后我们知道它绝对不会抛异常,所以要声明为noexcept,最终写法如下
template<typename E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept
{
    return static_cast<typename std::underlying_type<E>::type>(enumerator);
}
  • C++14中可以简化为
template<typename E>
constexpr std::underlying_type_t<E>
toUType(E enumerator) noexcept
{
    return static_cast<std::underlying_type_t<E>>(enumerator);
}
  • 再简化一点,C++14中可以将auto设为返回类型
template<typename E>
constexpr auto
toUType(E enumerator) noexcept
{
    return static_cast<std::underlying_type_t<E>>(enumerator);
}
  • 最终用在tuple上就是
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

11 使用=delete替代private未定义函数

  • 通常不希望某个函数被调用,只需要不声明这个函数即可。但对于一些编译器自动生成的函数,则无法这样做。目前只需要考虑拷贝构造函数和拷贝赋值运算符,C++98中阻止调用这些函数的做法是将其声明为private,并且不定义
  • 比如标准库所有的iostream都派生自basic_ios类模板,但不能对istream和ostream进行拷贝,因为无法确定这种行为,比如一个istream对象的一部分已被读取,一部分可能未来要读取,无法确定是否要全部拷贝还是拷贝部分。对这类问题最简单的处理方式就是定义其不存在,即禁止拷贝流,C++98中的basic_ios规定(包含注释)如下
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
    …
private:
    basic_ios(const basic_ios& ); // not defined
    basic_ios& operator=(const basic_ios&); // not defined
};
  • C++11中的取代做法是使用=delete标识deleted function,C++11中basic_ios的对应规定如下
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
    …
    basic_ios(const basic_ios& ) = delete;
    basic_ios& operator=(const basic_ios&) = delete;
    …
};
  • =delete和声明private除了看起来的风格以外还有一些细微区别。deleted function无法通过任何方法使用,即使成员和友元也无法赋值basic_ios类型对象,这对于C++98是一种改进,因为C++98在link-time才能诊断这个问题
  • 习惯上deleted function声明为public,这样使用某个函数时会先校验访问性后校验删除状态,能得到更好的诊断信息,而不会因为访问性为private而不知道删除状态
  • 不仅仅限于成员函数,任何函数都可以声明为deleted function,比如用来禁用重载以防止参数的隐式转换
void f(int);
void f(double) = delete; // 拒绝double和float类型参数
void f(char) = delete;
void f(bool) = delete;
f(3.14); // 错误
f('a'); // 错误
f(true); // 错误
  • 另一个trick是阻止模板不应该出现的实例化,比如有一个接受原始指针类型参数的模板
template<typename T>
void processPointer(T* ptr);
  • 而指针有两个特殊情况,一个是void,它无法解引用、自增、自减,另一个是char,它一般表示C-style字符串而非指向单个字符。不希望模板接受这两类指针,删除对应的两种特化即可
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;
  • 考虑更周全的话还有更多情况,比如有cv限定符的也应该删除,另外还有一些涉及其他字符类型的指针的重载版本也应该删除,如std::wchar_t、std::char16_t、std::char32_t
template<>
void processPointer<const void>(const void*) = delete;
template<>
void processPointer<const char>(const char*) = delete;
  • 类内的函数模板不能通过private声明来禁用某个实例化,因为不可能让特化版本的访问层级和主模板不同,模板特化必须写在命名空间作用域而非类作用域中
class Widget {
public:
    …
    template<typename T>
    void processPointer(T* ptr)
    { … }
private:
    template<> // 错误
    void processPointer<void>(void*);
};

class Widget {
public:
    …
    template<typename T>
    void processPointer(T* ptr)
    { … }
    …
};

template<> // 仍然是public但被删除了
void Widget::processPointer<void>(void*) = delete;
  • 事实上,C++98的做法就是对=delete的实际效果的模拟,自然不如本尊好用

12 将要重写的函数声明为override

  • 虚函数的重写(override)是很容易出错的事
class Base {
public:
    virtual void doWork(); // base class virtual function
    …
};
class Derived: public Base {
public:
    virtual void doWork(); // 重写Base::doWork
    …
};

std::unique_ptr<Base> upb = std::make_unique<Derived>();
…
upb->doWork(); // 通过基类指针调用派生类的doWork
  • 如果派生类中要完成重写,必须满足一系列要求
    • 基类中必须有此虚函数
    • 基类和派生类的函数名相同(析构函数除外)
    • 函数参数类型相同
    • const属性相同
    • 函数返回值和异常说明相同
  • C++11多出一条:引用修饰符(reference qualifier)相同。这是为了限制成员函数仅用于左值或右值
class Widget {
public:
    …
    void doWork() &; // *this是左值时才使用
    void doWork() &&; // *this是右值时才使用
};
…
Widget makeWidget(); // 工厂函数(返回右值)
Widget w; // 常规左值对象
…
w.doWork(); // 调用Widget::doWork &
makeWidget().doWork(); // 调用Widget::doWork &&
  • 对于这么多的要求,小的错误就可能造成大的偏差,比如下面代码没有任何override动作
class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;
};
class Derived: public Base {
public:
    virtual void mf1();
    virtual void mf2(unsigned int x);
    virtual void mf3() &&;
    void mf4() const;
};
  • 而上述代码是可以编译的,只会触发警告。为了保证正确性,C++11提供了override声明来标识要重写的函数,如果未重写就不能通过编译,加上override后能编译的版本如下
class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    virtual void mf4() const;
};
class Derived: public Base {
public:
    virtual void mf1() const override;
    virtual void mf2(int x) override;
    virtual void mf3() & override;
    void mf4() const override;
};
  • C++11加了两个语境关键字(contextual keyword):override和final。语境关键字的特点是只在特殊语境中保留,对override来说,只有出现在成员函数声明末尾才有保留意义,因此如果以前的遗留代码用到了override作为名字,升到C++11不需要改名
class Warning {
public:
    …
    void override(); // 在C++98和C++11中都合法
    …
};

成员函数引用修饰符

  • 成员函数引用修饰符针对的是发起调用的对象,即this,就和成员函数末尾声明const一样,表示发起调用this应该为const。下例提供一个对std::vector类型的数据成员的访问接口
class A {
public:
    using DataType = std::vector<double>;
    …
    DataType& data() { return values; }
    …
private:
    DataType values;
};

A a;
…
auto val1 = a.data(); // 返回std::vector<double>&
  • 假设有个工厂函数
Widget makeA();
auto val2 = makeA().data(); // 仍是返回左值引用
  • 这里仍是返回左值引用,而工厂函数返回的是右值,因此拷贝其中的std::vector就是浪费时间。好的做法是移动而非拷贝,因此要指定data在右值上调用值返回一个右值,用引用修饰符对data的左值和右值类型进行重载就解决了这个问题
class A {
public:
    using DataType = std::vector<double>;
    …
    DataType& data() & { return values; }
    DataType data() && { return std::move(values); } // 注意返回类型不是引用了
    …
private:
    DataType values;
};

auto val1 = a.data(); // 调用左值重载版本,拷贝构造val1
auto val2 = makeA().data(); // 调用右值重载版本,移动构造val2

13 使用const_iterator替代iterator

  • const_iterator是STL中pointer-to-const的等价物,它指向的值不能被修改
  • 在C++98中,const_iterator并不好使用。下例演示C++98在容器中搜索某个值,并在其位置插入一个新值
std::vector<int> values;
…
std::vector<int>::iterator it = std::find(values.begin(),values.end(), 1983);
values.insert(it, 1998);
  • 这里并没有修改iterator指向的内容,因此应该修改为const_iterator,但实际修改并没有预期的简单
typedef std::vector<int>::iterator IterT;
std::vector<int>::const_iterator ConstIterT;
std::vector<int> values;
…
ConstIterT ci = // const容器才能得到const_iterator,非const容器则需要强制转换
    std::find(static_cast<ConstIterT>(values.begin()), static_cast<ConstIterT>(values.end()),  1983);
values.insert(static_cast<IterT>(ci), 1998); // C++98中插入位置只能用iterator指定,但这不能通过编译
  • 然而上述代码除了冗长外,还不能通过编译,因为直到现在也不允许const_iterator到iterator的强制转换
  • 但C++11中这些现象被改变了,获取和使用const_iterator更容易了,容器的cbegin和cend就能直接返回const_iterator,任何需要迭代器而不需要修改值的时候都应该使用const_iterator
std::vector<int> values;
…
auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1998);
  • 只有在写最大化泛型的库时,C++11对const_iterator的支持不够充足。这类库代码将考虑某些容器或类似容器的数据结构,以非成员函数方式提供begin和end。这就是内置数组的情况,也是一些第三方库的情况。比如上述代码可以写成如下模板
// C++14
template<typename C, typename V>
void findAndInsert(C& container, const V& targetVal, const V& insertVal)
{
    using std::cbegin;
    using std::cend;
    auto it = std::find(cbegin(container), cend(container), targetVal);
    container.insert(it, insertVal);
}
  • 以上代码在C++14中可以运行,但在C++11中不行,因为C++11中只添加了非成员版本的begin和end,而没有cbegin、cend、rbegin、rend、crbegin、crend,不过用C++11也足够实现这类模板
template <class C> // C可以是内置数组类型,begin为数组提供了一个特化版本
auto cbegin(const C& container)->decltype(std::begin(container))
{
    return std::begin(container); // container是const,所以返回const_iterator
}

14 将不抛异常的函数声明为noexcept

  • C++98中,必须指出一个函数可能抛出的所有异常类型,如果函数有所改动则异常规范也要修改,而这可能破坏代码,因为调用者可能依赖于原本的异常规范,所以C++98中的异常规范被认为不值得招惹
  • C++11中达成了一个共识,真正需要关心的是函数会不会抛出异常,一个函数要么可能抛出异常,要么保证不会,这种maybe-or-never形成了C++11异常规范的基础,并有效地替代了C++98的异常规范(C++98的异常规范仍然有效,但已经被标为废弃特性)
  • 函数是否要加上noexcept声明与接口设计相关,是否抛异常行为是client的关注点。调用者可以查询函数的noexcept状态,查询结果将影响代码的异常安全性和执行效率。这样函数是否要声明为noexcept就和成员函数是否要声明为const一样重要,如果一个函数不抛异常却不为其声明noexcept,这就是接口规范缺陷
  • noexcept还有一个额外的好处,它可以让编译器生成更好的目标代码。为了理解原因只需要考虑C++98和C++11表达函数不抛异常的区别
int f(int x) throw(); // C++98
int f(int x) noexcept; // C++11
  • 如果运行期,一个异常逃出(leave)f,则f的异常规范被违反。在C++98中,call stack会展开到f的调用者,执行一些无关的动作后中止程序。C++11中略有不同的是,在程序中止前,stack只是可能展开。这一点微小的区别将对代码生成造成巨大的影响。noexcept声明的函数中,如果异常传出(propagate out of)函数,优化器不需要保持stack运行期的展开状态,也不需要在异常逃出(leave)时,保证其中所有的对象按构造顺序的逆序析构。而throw()声明的函数就没有这样的优化灵活性。总结起来就是
RetType function(params) noexcept; // most optimizable
RetType function(params) throw(); // less optimizable
RetType function(params); // less optimizable
  • 这个理由已经足矣支持给任何已知不会抛异常的函数加上noexcept,对某些函数来说更为典型,比如移动操作,假设有如下的C++98代码
std::vector<Widget> vw;
…
Widget w;
… // work with w
vw.push_back(w); // add w to vw
  • 如果想用移动语义来提高性能,则必须保证Widget有移动操作。std::vector在空间不够时,会扩展新的内存块,再把元素从现在的内存块转移到新的。C++98的做法是逐个拷贝,然后析构旧内存的对象,这使得push_back提供强异常安全保证:如果拷贝元素的过程中抛出异常,则std::vector保持原样,因为旧内存元素还未被析构。C++11的优化则是把拷贝替换成移动,但这违反了push_back的强异常安全保证,这是个严重问题,因为遗留代码可能依赖于push_back的强异常安全保证。因此,只有知道移动操作不会抛异常时才能用移动替代拷贝。std::vector::push_back利用了这种move if you can, but copy if you must的策略,而函数判断移动操作不会抛异常的做法就是校验这个操作的noexcept声明
  • swap函数是极其需要noexcept声明的另一个例子,它是许多STL算法实现的核心组件。有趣的是,标准库的swap是否带noexcept声明取决于用户定义的swap是否带noexcept声明,比如数组和std::pair的swap函数如下
template <class T, size_t N>
void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b))); // 见后续解释
template <class T1, class T2>
struct pair {
    …
    void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
        noexcept(swap(second, p.second)));
    …
};
  • 这些函数带有条件式noexcept声明,它们是否为noexcept取决于表达式结果是否为noexcept。比如对两个class A类型对象的数组使用上面的swap,noexcept需要通过其中的表达式判断,而其中的表达式又依赖于对数组元素swap是否为noexcept,即class A的swap是否为noexcept,而这是由class A的作者决定的。反过来说,类的swap是否声明为noexcept决定了上面的swap是否为noexcept
  • 不过要注意,虽然noexcept有优化的好处,但只有保证函数长期具有noexcept性质时才声明为noexcept,如果之后轻易地移除noexcept声明,就有破坏客户代码的风险。事实上大多数函数是异常中立的,它们本身不抛异常,但它们调用的函数可能抛异常,这样它们就允许抛出的异常传到call stack的更深一层,因此异常中立函数天生永远不具备noexcept性质
  • 有些函数有不抛异常的自然实现,就可以加上noexcept声明,但如果为了强行加上noexcept而修改实现就是本末倒置,比如调用一个会抛异常的函数是最简单的实现,为了不抛异常而环环相扣地来隐藏这点(比如捕获所有异常,将其替换成状态码或特殊返回值),大大增加了理解和维护的难度,并且这些复杂性的时间成本可能超过noexcept带来的优化
  • 对某些函数来说,noexcept性质十分重要,内存释放函数和所有的析构函数都隐式noexcept,这样就不必加noexcept声明。析构函数唯一未隐式noexcept的情况是,类中有数据成员的类型显式将析构函数声明noexcept(false),这样的析构函数很少见,标准库中一个也没有
  • 有些库的接口设计者会把函数区分为wide contract和narrow contract
  • wide contract函数没有前置条件,不用关心程序状态,对传入的实参没有限制,一定不会有未定义行为,如果知道不会抛异常就可以加上noexcept
  • 而如果narrow contract函数的前置条件被违反,则结果未定义。比如一个函数的参数是std::string,假设函数的自然实现不会抛异常,则应该加上noexcept。但如果假设函数有个前提是std::string不能超过32个字符,否则行为未定义。函数没有义务校验这个前置条件,因为它断言前置条件一定满足(调用者负责保证断言成立),即使有前置条件,加上noexcept声明也是合理的
void f(const std::string& s) noexcept; // 前置条件:s.length() <= 32
  • 但再假设函数实现选择校验违反前置条件的情况,那如何报告前置条件违例是个问题。一个直接的做法是抛出一个“前置条件违例”的异常,但如果函数加了noexcept声明,异常就会导致程序中止。也正因此,库作者才会区分wide contract函数和narrow contract函数,并一般只为wide contract函数声明noexcept
  • 编译器一般不支持函数实现的异常规范一致性
void setup(); // functions defined elsewhere
void cleanup();
void doWork() noexcept
{
    setup(); // set up work to be done
    … // do the actual work
    cleanup(); // perform cleanup actions
}
  • 这里doWork带noexcept声明,尽管它调用了不带noexcept声明的函数。这看起来自相矛盾,但也许是调用的函数在文档中写明了不会抛异常,也许它们来自C语言撰写的库(比如std::strlen就不带noexcept),也许来自还没来得及根据C++11标准做修订的C++98库

15 尽可能使用constexpr

  • constexpr用于对象时就是一个加强版的const,表面上看constexpr表示值是const,且在编译期(严格来说是翻译期,包括编译和链接,如果不是编译器或链接器作者,无需关心这点区别)已知,但用于函数则有不同的意义
  • 编译期已知的值可能被放进只读内存,这对嵌入式开发是一个很重要的语法特性。扩展开来,编译期已知的常量可以用来表示数组尺寸、整型模板实参(包括std::array的长度)、枚举值、对齐规格
int i; // non-constexpr变量
…
constexpr auto j = i; // 错误:i的值在编译期未知
std::array<int, i> v1; // 错误:同上
constexpr auto n = 10; // OK:10是一个编译期常量
std::array<int, n> v2; // OK:n是constexpr
  • const对象不一定由编译期已知值初始化,不提供和constexpr一样的保证
int i; // non-constexpr变量
…
const auto n = i; // OK
std::array<int, n> v; // 错误:n值非编译期已知
  • constexpr函数在调用时若传入的是编译期常量,则产出编译期常量,传入运行期才知道的值,则产出运行期值。这只是个简单理解,具体规则是
    • 在编译期语境中,constexpr函数的实参必须在编译期已知,并在编译期计算出结果
    • 调用constexpr函数时,如果实参都在编译期已知则产出编译期值,如果有编译期未知的传入值则和普通函数一样,执行运行期计算。constexpr函数可以满足所有需求,因此不必为了有非编译期值的情况而写两个函数
constexpr int pow(int base, int exp) noexcept
{
    … // 实现见后
}
constexpr auto n = 5;
std::array<int, pow(3, n)> results;
  • 上面的constexpr并不表示函数要返回const值,而是表示,如果参数都是编译期常量,则返回结果就可以当编译期常量使用,如果有一个不是编译期常量,返回值就在运行期计算。这样函数不仅可以用于std::array尺寸,还可以用于运行期语境
auto base = readFromDB("base"); // 运行期获取值
auto exp = readFromDB("exponent"); // 运行期获取值
auto baseToExp = pow(base, exp); // pow在运行期被调用
  • 由于constexpr要在传入编译期常量时返回编译期结果,所以实现需要进行限制,而这个限制在C++11和C++14中有所不同
  • C++11中,constexpr函数不能包含多个可执行语句,只能有一条return语句。有两个应对限制的技巧:用条件运算符?:替代if-else、用递归替代循环
constexpr int pow(int base, int exp) noexcept
{
    return (exp == 0 ? 1 : base * pow(base, exp - 1));
}
  • C++14中则放宽了限制
// C++14
constexpr int pow(int base, int exp) noexcept
{
    auto result = 1;
    for (int i = 0; i < exp; ++i) result *= base;
    return result;
}
  • constexpr函数仅限于传入和返回字面值类型,C++11中除了void的所有内置类型都满足条件,但自定义类型也可能是字面值,因为它的构造函数和其他成员函数可能是constexpr函数
class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept
    : x(xVal), y(yVal) {}
    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }
    void setX(double newX) noexcept { x = newX; } // 修改了对象所以不能声明为constexpr
    void setY(double newY) noexcept { y = newY; } // 另一个原因是返回的void不是字面值类型
private:
    double x, y;
};

constexpr Point p1(9.4, 27.7); // 编译期执行constexpr构造函数
constexpr Point p2(28.8, 5.3); // 同上

// 通过constexpr Point对象调用xValue和yValue也会在编译期获取值
// 于是可以再写出一个新的constexpr函数
constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
    return { (p1.xValue() + p2.xValue()) / 2, (p1.yValue() + p2.yValue()) / 2 };
}
constexpr auto mid = midpoint(p1, p2); // mid在编译期创建
  • 因为mid是编译期已知值,这就意味着如下表达式可以用于模板形参或指定枚举量的表达式中
mid.xValue()*10
// 因为上式是浮点型,浮点型不能用于模板实例化,因此还要如下转换一次
static_cast<int>(mid.xValue()*10)
  • C++14中解除了上述setX和setY不能声明为constexpr的限制
// C++14
class Point {
public:
    …
    constexpr void setX(double newX) noexcept { x = newX; }
    constexpr void setY(double newY) noexcept { y = newY; }
    …
};
// 于是C++14允许写出下面的代码
constexpr Point reflection(const Point& p) noexcept // 返回p关于原点的对称点
{
    Point result; // 创建non-const Point
    result.setX(-p.xValue()); // setX
    result.setY(-p.yValue()); // setY
    return result;
}

constexpr Point p1(9.4, 27.7);
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);
constexpr auto reflectedMid = reflection(mid); // 值为(-19.1, -16.5),且在编译期已知
  • 和noexcept的使用前提一样,尽可能使用constexpr的前提是,必须长期保证需要它,因为如果后续要删除constexpr可能会导致许多错误

16 保证const成员函数线程安全

  • 假设有一个表示数学中的多项式的类,其中有一个计算此多项式的根(多项式为零时方程的解)的函数,因为函数不改变多项式值,自然会将其声明为const。求解多项式的根可能开销巨大,为了不多次重复计算,将根存进缓存作为函数的返回值
class Polynomial {
public:
    using RootsType = std::vector<double>; // 存储根的数据结构
    RootsType roots() const
    {
        if (!rootsAreValid) { // 缓存无效则计算根
            …
            rootsAreValid = true;
        }
        return rootVals;
    }
private:
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
  • 概念上函数roots不改变类对象,但可能需要修改rootVals和rootsAreValid值,const函数中值要被修改则需要声明为mutable
  • 假如此时有两个线程在同一个Polynomial对象上调用roots
Polynomial p;
…
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();
  • roots是const函数,代表一个读操作,多线程未同步时进行读操作被认为是安全的,但在这里并不安全。上述代码行为未定义,因为roots内部修改了数据成员,这意味着可能有多个线程在不同步的情况下读写同一块内存,产生数据竞争。最简单的解决方法是引入一个mutex
public:
    using RootsType = std::vector<double>; // 存储根的数据结构
    RootsType roots() const
    {
        std::lock_guard<std::mutex> g(m); // lock mutex
        if (!rootsAreValid) { // 缓存无效则计算根
            …
            rootsAreValid = true;
        }
        return rootVals;
    } // unlock mutex
private:
    mutable std::mutex m;
    mutable bool rootsAreValid{ false };
    mutable RootsType rootVals{};
};
  • 因为std::mutex是move-only类型,其副作用就是使Polynomial只能移动不能复制
  • 对一些特殊情况,引入mutex是大材小用,比如计算一个成员函数被调用次数,使用std::atomic可能开销更低(实际取决于硬件及mutex的实现)
class Point {
public:
    …
    double distanceFromOrigin() const noexcept
    {
        ++callCount; // 原子性的自增操作
        return std::sqrt((x * x) + (y * y));
    }
private:
    mutable std::atomic<unsigned> callCount{ 0 };
    double x, y;
};
  • std::atomic同样是move-only类型,因此Point也会变成move-only类型
  • 对std::atomic变量的操作比起获取与解锁mutex开销更低,因此可以过度一些使用std::atomic,比如一个类要缓存计算开销较大的int,则应该用std::atomic取代mutex
class Widget {
public:
    …
    int magicValue() const
    {
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2; // part1
            cacheValid = true; // part2
            return cachedValue;
        }
    }
private:
    mutable std::atomic<bool> cacheValid{ false };
    mutable std::atomic<int> cachedValue;
};
  • 这样做可行,但有时将过度工作,比如多个线程调用函数时观察到cacheValid值为false,都进行了大开销运算,缓存反而没有起到预期作用
  • 改变part1和part2的顺序,先设置cacheValid为true(part2)可以消除这个问题,但结果更糟
class Widget {
public:
    …
    int magicValue() const
    {
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cacheValid = true; // part1
            cachedValue = val1 + val2; // part2
            return cachedValue;
        }
    }
private:
    mutable std::atomic<bool> cacheValid{ false };
    mutable std::atomic<int> cachedValue;
};
  • 假如chacheValid为false,线程1执行到part1,线程2检查到cacheValid值为true并直接返回cachedValue值,然后线程1才执行part2,这样线程2的返回值就是错的
  • 因此,对单个要求同步的变量或内存区,std::atomic就够用了,对多个则要用上mutex
class Widget {
public:
    …
    int magicValue() const
    {
        std::lock_guard<std::mutex> guard(m); // lock m
        if (cacheValid) return cachedValue;
        else {
            auto val1 = expensiveComputation1();
            auto val2 = expensiveComputation2();
            cachedValue = val1 + val2;
            cacheValid = true;
            return cachedValue;
        }
    } // unlock m
    …
private:
    mutable std::mutex m;
    mutable int cachedValue; // 不再有原子性
    mutable bool cacheValid{ false }; // 不再有原子性
};

17 理解特殊成员函数的生成机制

  • C++11中的特殊成员函数多了两个:移动构造函数和移动赋值运算符
class A {
public:
    …
    A(A&& rhs); // 移动构造函数
    A& operator=(A&& rhs); // 移动赋值运算符
    …
};
  • 移动操作同样会在需要时生成,执行的是对非静态成员的移动操作,另外它们也会对基类部分执行移动操作
  • 移动操作并不确保真正移动,其核心是把std::move用于每个要移动的对象,根据返回值的重载解析决定执行移动还是拷贝。因此按成员移动分为两部分:对支持移动操作的类型进行移动,对不可移动的类型执行拷贝
  • 两种拷贝操作是独立的,声明其中一个不会阻止编译器生成另一个
  • 两种移动操作是不独立的,声明其中一个将阻止编译器生成另一个。理由是如果声明了移动构造函数,可能意味着实现上与编译器默认按成员移动的移动构造函数有所不同,从而可以推断移动赋值操作也应该与默认行为不同
  • 显式声明拷贝操作(即使声明为=delete),也会阻止自动生成移动操作(但声明为=default不阻止生成)。理由类似上条,声明拷贝操作可能意味着默认的拷贝方式不适用,从而推断移动操作也应该会默认行为不同
  • 反之亦然,声明移动操作也会阻止生成拷贝操作
  • C++11规定,显式声明析构函数会阻止生成移动操作。这个规定源于Rule of Three,即两种拷贝函数和析构函数应该一起声明。这个规则的推论是,如果声明了析构函数,则说明默认的拷贝操作也不适用,但C++98中没有重视这个推论,因此仍可以生成拷贝操作,而在C++11中为了保持不破坏遗留代码,保留了这个规则。由于析构函数和拷贝操作需要一起声明,加上声明了拷贝操作会阻止生成移动操作,于是C++11就有了这条规定
  • 最终,生成移动操作的条件必须满足:该类没有用户声明的拷贝、移动、析构中的任何一个函数
  • 总有一天这个规则会扩展到拷贝操作,因为C++11规定存在拷贝操作或析构函数时,仍能生成拷贝操作是被废弃的行为。C++11提供了=default表达使用默认行为,而不抑制生成其他函数
class A {
public:
    …
    ~A(); // 用户定义的析构函数
    …
    A(const A&) = default; // 显式声明使用默认拷贝构造函数的行为
    A& operator=(const A&) = default; // 显式声明默认拷贝赋值运算符
    …
};
  • 这种手法对于多态基类很有用,多态基类一般会有虚析构函数,虚析构函数的默认实现一般是正确的,为了使用默认行为而不阻止生成移动操作,则应该使用=default,同理,如果要使用默认的移动操作而不阻止生成拷贝操作,则应该给移动操作加上=default
class A {
public:
    virtual ~A() = default;
    A(A&&) = default; // support moving
    A& operator=(A&&) = default;
    A(const A&) = default; // support copying
    A& operator=(const A&) = default;
    …
};
  • 事实上不需要思考太多限制,如果需要默认操作就使用=default,虽然麻烦一些,但可以避免许多问题
class StringTable { // 表示字符串表格的类
public:
    StringTable() {}
    … // 实现插入、删除、查找等函数
private:
    std::map<int, std::string> values;
};
  • 上面的类没有声明任何特殊成员函数,编译器将在需要时自动合成,但假设过一段时间想把默认构造和析构记入日志,就会加上用户声明的构造函数和析构函数
class StringTable {
public:
    StringTable() { makeLogEntry("Creating StringTable object"); }
    ~StringTable() { makeLogEntry("Destroying StringTable object"); }
    …
private:
    std::map<int, std::string> values; // as before
};
  • 这时析构函数就会阻止生成移动操作,但针对移动操作的测试可以通过编译,因为在不可移动时会使用拷贝操作,而这很难被察觉。对StringTable执行移动的代码实际变成了拷贝,也就是其中std::map<int, std::string>的拷贝,其拷贝开销可能远大于移动,而这一切导致性能问题只是源于简单自然添加的析构函数,这个问题也只需要把拷贝和移动操作声明为=default就能避免
  • 另外还有默认构造函数和析构函数的生成未被提及,这里将统一总结
    • 默认构造函数:和C++98相同,只在类中不存在用户声明的构造函数时生成
    • 析构函数:
      • 和C++98基本相同,唯一的区别是默认为noexcept
      • 和C++98相同,只有基类的析构函数为虚函数,派生类的析构函数才为虚函数
    • 拷贝构造函数:
      • 仅当类中不存在用户声明的拷贝构造函数时生成
      • 如果声明了移动操作,则拷贝构造函数被删除
      • 如果声明了拷贝赋值运算符或析构函数,仍能生成拷贝构造函数,但这是被废弃的行为
    • 拷贝赋值运算符:
      • 仅当类中不存在用户声明的拷贝赋值运算符时生成
      • 如果声明了移动操作,则拷贝赋值运算符被删除
      • 如果声明了拷贝构造函数或析构函数,仍能生成拷贝赋值运算符,但这是被废弃的行为
    • 移动操作:仅当类中不存在任何用户声明的拷贝操作、移动操作、析构函数时生成
  • 注意,这些机制中提到的是成员函数而非成员函数模板,模板并不会影响生成
class A {
…
    template<typename T>
    A(const T& rhs); // 从任意类型构造
    template<typename T>
    A& operator=(const T& rhs); // 从任意类型赋值
    …
};
  • 上述模板不会阻止编译器生成拷贝和移动操作,即使模板的实例化和拷贝操作签名相同(即T是A)

相关文章

网友评论

    本文标题:【Effective Modern C++(3)】转向现代C++

    本文链接:https://www.haomeiwen.com/subject/skvccqtx.html