3. 移步现代C++

45 阅读17分钟

7. 区别使用(){}创建对象

  1. {}是统一初始化,因为()=有些情况下分别没法使用,前者无法在类中直接初始化成员,后者无法在禁用拷贝赋值之后再赋值,atomic<int> a1 = 0;//报错
  • 用临时对象初始化新对象(如 T obj = T(args);)会拷贝消除,直接在obj中进行构造
  • RVO:当函数返回一个临时对象(纯右值,prvalue)时,编译器可以跳过临时对象的构造和析构,直接在调用方分配的内存中构造返回值。
  • NRVO:RVO 的特例,针对函数返回具名局部变量(非临时对象)的优化,局部变量 直接在调用方的栈帧上构造
  1. C++标准规定:任何能被解析为函数声明的表达式都会被当作函数声明。如Widget w();本意我们是希望创建对象w,但是会被解析为函数声明,这种情况称为最令人烦恼的解析(Most Vexing Parse, MVP)
  2. {}能解决MVPWidget w{};,这里会调用默认构造函数,空的花括号意味着没有实参,不是一个空的std::initializer_list
  3. {}还能阻止隐式窄化型别转换(如double->int),但是自己也存在一些问题
  4. {}会强烈地优先选用带有std::initialize_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转换为double)
  • 在构造函数重载决议中,编译器会尽最大努力将括号初始化与std::initializer_list参数匹配,即便其他构造函数看起来是更好的选择
class Widget { 
public: 
    Widget(int i, bool b);                      //同之前一样
    Widget(int i, double d);                    //同之前一样
    Widget(std::initializer_list<bool> il);     //现在元素类型为bool//没有隐式转换函数
};

Widget w{10, 5.0};              //错误!要求变窄转换,而不是调用构造
  • 只有当没办法把括号初始化中实参的类型转化为std::initializer_list时,编译器才会回到正常的函数决议流程中。
class Widget { 
public:  
    Widget(int i, bool b);                              //同之前一样
    Widget(int i, double d);                            //同之前一样
    //现在std::initializer_list元素类型为std::string
    Widget(std::initializer_list<std::string> il);
    …                                                   //没有隐式转换函数
};

Widget w1(10, true);     // 使用圆括号初始化,调用第一个构造函数
Widget w2{10, true};     // 使用花括号初始化,现在调用第一个构造函数
Widget w3(10, 5.0);      // 使用圆括号初始化,调用第二个构造函数
Widget w4{10, 5.0};      // 使用花括号初始化,现在调用第二个构造函数
  1. 使用()和{}会造成结果大相径庭,如std::vector
std::vector<int> v1(10, 20);    //使用非std::initializer_list构造函数
                                //创建一个包含10个元素的std::vector,
                                //所有的元素的值都是20
std::vector<int> v2{10, 20};    //使用std::initializer_list构造函数
                                //创建包含两个元素的std::vector,
                                //元素的值为10和20

建议

  1. 作为库的开发者,注意是否声明接收std::initailizer_list的构造函数,但是尽量让小括号大括号不影响结果
  2. 作为库的使用者,默认使用花括号 {} 初始化,除非:
  • 需要调用非 initializer_list 构造函数
  • 可能引起构造函数选择混乱

避免使用 = 初始化,容易混淆赋值与初始化语义

  1. 对于模板的开发,使用接收参数创建对象是使用()还是{},其实开发者并不知道,只有调用者才知道,需要把控制权给开发者,比如给出两个不同的模板或标签
template<typename T, typename... Args>
T create(Args&&... args) {
    return T(/* 这里用()还是{}? */);
}

// 调用者可能希望:
auto v1 = create<std::vector<int>>(3, 5);  // 期望 (3,5) → [5,5,5]
auto v2 = create<std::vector<int>>(3, 5);  // 期望 {3,5} → [3,5]

8. 优先考虑nullptr而非0NULL

nullptr 的类型特性

一句话:空指针就是nullptr

  • 类型:std::nullptr_t
  • 隐式转换为任意指针类型
  • 不能转换为整数类型,因此不会误选 int 重载版本。

考虑nullptr原因

  1. 0 和 NULL 本质是整型,不是指针,会导致重载决议中的意外,使用nullptr能避免且能提升代码的清晰性,有更明确的语义
```,尤其在涉及`auto`变量时
```cpp
void foo(int* ptr) {}
void foo(int val) {}

foo(NULL);  // 编译错误:重载歧义(可能是int*或int)
  1. 在模板中,NULL0会被推导为int,无法隐式转换为指针类型

9. 优先考虑别名声明using而非typedef

  1. 别名模板:为复杂的模板类型定义简洁的别名,给类型模板“取别名”
    template<class T>
    using MyAllocList = std::list<T,MyAlloc<T>>;
    
    而typedef实现:
    template<class T>
    struct MyAllocList{
        typedef std::list<T,MyAlloc<T>> type;
    };
    
    这是属于嵌套从属类型,在模板中使用:typename MyAllocList<T>::type list;,而使用别名模板则只需要使用MyAllocList<T> list;,不用加上typename

这里别名模板可以用来进行类型萃取,即将模板类型参数提取成我们想要的参数,比如std::remove_const<T>::type就是用来去掉const属性的,c++14中进而可以写成std::remove_const_t<T>

template<class T>
using remove_const_t = remove_const<T>::type;

10. 优先考虑限域enum而非未限域enum

  1. 枚举本身就是类型,所以将枚举值传入模板中,推导出来的就是枚举类型,如enum Color{...};Color c = red;c的类型就是Color
  2. 未限域enum:enum Color;限域enum:enum class Color
  3. 非限域/限域enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型,可通过定义时显式指定:enum class Color : int{...};指定其底层为int
  4. 未限域enum指枚举量的名字泄露到枚举类别所在作用域,如red可直接被使用
  5. 未限域enum枚举量可以隐式转换到其他类型,比如整形、浮点型:Color c = red;if(c<2.4){...}能直接通过编译;而enum class拥有更强的类型,对于这些转换都需要显示转换,更加合理
  6. 默认未限域enum不能前置声明,因为底层类型还需要内部值的范围推导出来,导致如果内部元素更改,就需要重新编译整个文件;而限域enum可以前置声明,因为其默认底层类型是int;当然如果使用enum color:int;是可以前置声明的
  7. 未限域enum也是有用的,比如去tuple中的元素:
using UserInfo =                //类型别名,参见Item9
    std::tuple<std::string,     //名字
               std::string,     //email地址
               std::size_t> ;   //声望
UserInfo uInfo;                 //tuple对象// 取字段
// 1. 如果直接用数字,这是很不好的设计,还需要我们去记忆
// auto val = std::get<1>(uInfo);

// 2. 借助未限域,因为
// - 枚举量可以直接获取
// - 枚举量可以直接隐式转换成size_t
enum UserInfoFields { uiName, uiEmail, uiReputation };
auto val = std::get<uiEmail>(uInfo);    //啊,获取用户email字段的值

// 3. 使用限域enum
enum class UserInfoFields { uiName, uiEmail, uiReputation };
auto val =
    std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
        (uInfo);

// 4. 使用模板优化上面的限域enum
// - 用模板获取底层类型
// - 然后用底层类型来赋值给size_t类型,这里就可隐式转换了
template<typename E> // 这里E会推导为枚举类型,如Color
constexpr typename std::underlying_type<E>::type
    toUType(E enumerator) noexcept
{// 这里std::underlying_type是类型萃取器,参考条款9,获取枚举的底层类型
    return
        static_cast<typename
                    std::underlying_type<E>::type>(enumerator);
}
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

11. 优先考虑使用deleted函数而非使用未定义的私有声明

  1. 格式:函数声明 = delete;,删除函数一般放置于public,因为cpp会先校验可访问性,再校验删除状态
  2. 删除函数适用于所有函数,不仅仅包括成员函数,还有普通函数、模板函数
  3. 删除成员函数,是为了阻止编译器生成默认函数,比如默认构造函数等
  4. 删除普通函数,是为了阻止不合理的隐式转换;因为虽然deleted函数不能被使用,但它们还是存在于你的程序中。也即是说,重载决议会考虑它们,发现他们被删除之后会直接报错。
    bool isLucky(int number);       //原始版本
    bool isLucky(char) = delete;    //拒绝char
    bool isLucky(bool) = delete;    //拒绝bool
    bool isLucky(double) = delete;  //拒绝float和double
    
  1. 提升(Promotion)  是隐式的、无损的转换(如 char→intfloat→double),优先级高于 标准转换(Standard Conversion) (如 int→longfloat→int
  2. 这里删除double拒绝了float,是因为float会优先提升到double
  1. 删除特例化模板函数,是为了阻止不该进行的模板具现,比如操作指针的模板,就不应该对void *进行操作,但是在删除时要注意一些修饰,比如const
template<typename T>
void processPointer(T* ptr);

template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<void>(const void*) = delete;
//想做得更彻底一些,你还要删除`const volatile void*`和`const volatile char*`重载版本

12. 使用override声明重写函数

overridevirtual以及final仅声明时使用的关键字,与const 、引用限定符(&&& 不一样,前者在类外实现是不允许再写此关键字,后者必须同时出现在类外定义中,否则函数签名不匹配,定义了一个新函数

  1. override是重写,overload是重载
  2. 返回类型不影响overload,但是影响override
  3. override要求基类函数必须是虚函数,函数签名(函数名与形参)、返回类型以及函数常量性、引用饰词必须完全相同;写上关键字override,就能检测这些是否满足,而不至于可能之间有不同而导致了隐藏,进而让逻辑出错

引用饰词其实也是修饰*this传入的是左值还是右值

class Widget {
public:
    …
    void doWork() &;    //只有*this为左值的时候才能被调用
    void doWork() &&;   //只有*this为右值的时候才能被调用
}; 
…
Widget makeWidget();    //工厂函数(返回右值)
Widget w;               //普通对象(左值)
…
w.doWork();             //调用被左值引用限定修饰的Widget::doWork版本
                        //(即Widget::doWork &)
makeWidget().doWork();  //调用被右值引用限定修饰的Widget::doWork版本
                        //(即Widget::doWork &&)
  1. overridefinal必须放置于函数声明最后,即const、引用饰词等之后;同时,可以允许自定义名为override的函数
  2. override 允许安全使用 协变返回类型
class Base {
public:
    virtual Base* clone();
};

class Derived : public Base {
public:
    Derived* clone() override;  // ✅ 合法协变返回
};

13. 优先考虑const_iterator而非iterator

  1. 只要不需要修改元素,就应优先使用 const_iterator
  • const_iterator 只保证不能通过它修改元素本身
  • 不限制操作容器结构(如插入、删除)
  1. const_iterator获取:
  • 可通过const容器调用beginend获取
  • cpp11可通过成员方法cbegincend
  • cpp14可通过非成员函数模板获取cbegincendcrbegincrend

cpp11可通过模板自己实现cbegin等方法

template<class T>
auto cbegin(const T& container)->decltype(container.begin()){
    container.begin(); 
}
  1. 为了兼容原生数组和仅支持非成员函数的容器,如第三方库,优先使用非成员函数,但是主语cpp11需要手动提供
using std::cbegin;
using std::cend;
auto it = std::find(cbegin(container), cend(container), val);
  1. 一般代码中
  • 用 cbegin() / cend() 代替 begin() / end() ,只要不打算修改元素。
  • 使用 auto 推导,配合 const_iterator 更方便。
  1. 泛型模板代码中
  • 用非成员版本的 cbegin() / cend()using std::cbegin;)。
  • 如使用 C++11,手动提供缺失的非成员版本。

14. 如果函数不抛出异常请使用 noexcept

补充:cpp异常处理

  1. cpp没有异常是搭配默认捕获的,都是需要手动写try-catch,如果异常未被捕获,程序会调用 std::terminate() 终止
  2. cpp的throw-catch类似于参数传递,不同之处在于throw之后局部变量都会被销毁,所以不能throw出局部变量的指针或引用,而且C++ 异常传递仅支持 派生类到基类 的转换(多态),且必须通过引用或指针传递。其他隐式转换(如 intdouble)会被禁止
  3. cpp的默认捕获是catch(...),能捕获所有异常,但是无法直接访问异常对象
  4. throw可以抛出
  • 基本数据类型,如int
  • 抛出标准库异常类,都继承自 std::exception,比如std::out_of_range("Out of range")
  • 自定义异常类,继承自 std::exception或其派生类
  1. C/C++存在很多的未定义行为,如果程序中使用了未定义行为,那么得到的结果是不可知的,可能会导致编译报错,也可能导致运行出错等,还可能无事发生,具体结果可能与平台/编译器有关
  2. 在标准库中,有些操作就会抛出错误(如new),这些可以查询相关文档是能查到的
  3. 栈展开是 C++ 在抛出异常时,按调用链反向析构局部对象并清理栈帧的过程,确保资源不泄漏。
#include <exception>
#include <string>
class MyException : public std::exception {
private:
    std::string message;
public:
    // 构造函数
    explicit MyException(const std::string& msg) : message(msg) {}
    
    // 重载what()方法
    const char* what() const noexcept override {
        return message.c_str();
    }
};

// 使用
throw MyException("This is my custom exception");

noexcept语法

  1. 说明符声明函数是否抛出异常
void func() noexcept;      // 声明 func 不抛出任何异常
void func() noexcept(true); // 同上,显式指定不抛异常
void func() noexcept(false); // 声明 func 可能抛出异常
  • 若标记为 noexcept 的函数抛出异常,程序直接调用 std::terminate() 终止。
  1. 操作符noexcept(expr)编译时检查表达式是否会抛出异常;
template<typename T> 
void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
    // noexcept(a.swap(b))检查T::swap是否为noexcept
    // 是则返回true,不是则为false,进而导致外部的noexcept()里面是true or false
    a.swap(b); // 异常安全性由 T::swap 决定 
}

为什么使用 noexcept

  1. 更安全的接口设计:
    noexcept 是函数接口的一部分,明确告知调用者「不会抛异常」。所以定义时不需要再指出
  2. 启用更多优化:
    编译器对 noexcept 函数可以省略异常处理机制,生成更高效代码。
  3. 支持 STL 的强异常安全保证:
    如 std::vector::push_backstd::swap 等需要知道操作是否会抛异常,才能决定是否使用移动操作。

哪些场景使用

  1. 明确不会抛异常的函数
  2. 移动构造、移动赋值以及swap函数
  3. 析构、delete操作符默认是noexcept
  • 只有当其成员的析构函数为 noexcept(false) 才不为 noexcept
class Widget {
public:
    ~Widget() { 
        throw std::runtime_error("Oops"); // ❌ 触发 std::terminate()
    }
};

int main() {
    try { Widget w; } 
    catch (...) {} // 不会执行,程序直接终止
}
  1. 不要滥用noexcept,如果内部调用的函数会抛异常,就不能再声明noexcept;同时也不能故意隐藏错误,比如将错误捕获然后返回状态码

STL中的noexcept

  1. STL 会使用 std::move_if_noexcept()
  • 所以对于自定义类,需要将移动构造赋值声明为noexcept,才能让容器在扩容时才会使用移动函数:
if (is_nothrow_move_constructible<T>::value || !is_copy_constructible<T>::value)
    std::move(t); // 否则保守地使用复制
  1. std::swap 是否 noexcept 取决于你提供的类型 T 的 swap 是否 noexcept
template<typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b)));

异常与析构

  1. 尽管可以显示指定析构为noexcept(false)(历史包袱),但是尽量不允许析构抛出异常,因为可能会导致同时抛出两个异常,进而导致程序结束
  struct Bad {
      ~Bad() noexcept(false) { throw 42; } // ❌ 危险
  };
  
  void foo() {
      Bad b;
      throw std::runtime_error("oops"); // 触发栈展开时析构 b,导致 terminate
  }

15. 尽可能使用 constexpr

constexpr作用

  1. 作用于对象,则代表该对象既是const,又是编译期常量
  2. 作用于函数,在符合规则的情况下,如果传入编译期常量,结果也会是编译期常量;否则在运行期计算

语法规则

  1. 作用于对象,要求必须是编译期常量,所以并非所有 const 对象都是 constexpr
constexpr int size = 10;
std::array<int,size> arr; // ok

const int runtimeSize = getValue(); // const并不是constexpr
std::array<int,runtimeSize> arr; // 编译错误,指定数组大小必须编译期常量
  1. 作用于函数
  • cpp11要求内部实现只能有一句、返回类型必须是字面量类型,同时作用于成员函数时默认是const的

字面量类型:

  1. 基本类型(除void)
  2. 自定义类型,但是构造函数声明了constexpr
const int pow(int base,int exp) noexcept{
    return (exp==0 ? 1 : base * pow(base,exp-1));
}
  • cpp14放开了上述要求
class Point {
public:
    constexpr Point(double x = 0, double y = 0) noexcept : x(x), y(y) {}
    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }
	
    // 可以有非常量成员函数
    constexpr void setX(double newX) noexcept { x = newX; }  // C++14+
    constexpr void setY(double newY) noexcept { y = newY; }  // C++14+
 
private:
    double x, y;
};

constexpr Point midpoint(const Point& p1, const Point& p2) noexcept {
    return Point{ (p1.xValue() + p2.xValue()) / 2,
                  (p1.yValue() + p2.yValue()) / 2 };
}

总结

建议原因
尽可能使用 constexpr提高函数和变量的适用范围,提升性能与可靠性
优先用于编译期必须常量的位置如模板参数、数组大小、alignas、枚举值等
C++14 更适合 constexpr 编程支持更丰富的函数体和返回类型
不盲目添加一旦添加,影响接口兼容性

16. 让 const 成员函数线程安全

不安全原因

const成员函数依然可以修改内部mutable成员,此时可能导致资源竞争

解决方案

  1. 单变量修改可以使用std::atomic,开销更小
  2. 多变量就得使用mutable

使用互斥量或原子操作会影响类的复制和移动特性(一般不可复制不可移动)

17. 理解特殊成员函数的生成

什么是特殊成员函数?

特殊成员函数是编译器在特定条件下自动生成的成员函数,主要包括:

  • 默认构造函数(当没有其他构造函数时)
  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符

C++11新增的两个移动操作:

  • 移动构造函数
  • 移动赋值运算符

关键点:只有当代码需要使用这些函数但开发者没有显式声明时,编译器才会自动生成它们。

生成规则详解

  1. 基本特性
  • 所有自动生成的函数都是publicinline
  • 移动操作会智能处理成员:对可移动的成员执行移动,对基本类型等不可移动的成员执行拷贝
  1. 大三法则(Rule of Three)
  • 如果你显式声明了拷贝构造、拷贝赋值或析构函数中的任意一个,就应该同时声明这三个
  • 原因:声明其中任何一个通常意味着类需要管理资源,因此需要完整的资源管理逻辑
  1. 移动操作的特殊规则
  • 两个移动操作相互影响:声明其中一个会导致另一个不会被自动生成
  • 移动操作只在类中没有用户声明的拷贝操作、移动操作和析构函数时才会生成

重要结论

  1. 移动操作的生成条件最严格
  • 需要类中没有任何用户声明的拷贝操作、移动操作和析构函数
  1. 析构函数的特殊变化
  • 在C++11后,默认生成的析构函数是noexcept
  1. 拷贝操作的隐藏规则
  • 如果声明了移动操作,拷贝操作会被隐式删除(除非显式声明)

实际建议:对于资源管理类,要么遵循大三法则显式实现所有拷贝操作和析构函数,要么使用=default明确使用编译器生成的版本。