7. 区别使用()和{}创建对象
{}是统一初始化,因为()和=有些情况下分别没法使用,前者无法在类中直接初始化成员,后者无法在禁用拷贝赋值之后再赋值,atomic<int> a1 = 0;//报错
- 用临时对象初始化新对象(如
T obj = T(args);)会拷贝消除,直接在obj中进行构造- RVO:当函数返回一个临时对象(纯右值,prvalue)时,编译器可以跳过临时对象的构造和析构,直接在调用方分配的内存中构造返回值。
- NRVO:RVO 的特例,针对函数返回具名局部变量(非临时对象)的优化,
局部变量直接在调用方的栈帧上构造
- C++标准规定:任何能被解析为函数声明的表达式都会被当作函数声明。如
Widget w();本意我们是希望创建对象w,但是会被解析为函数声明,这种情况称为最令人烦恼的解析(Most Vexing Parse, MVP) {}能解决MVPWidget w{};,这里会调用默认构造函数,空的花括号意味着没有实参,不是一个空的std::initializer_list{}还能阻止隐式窄化型别转换(如double->int),但是自己也存在一些问题- {}会强烈地优先选用带有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}; // 使用花括号初始化,现在调用第二个构造函数
- 使用()和{}会造成结果大相径庭,如
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
建议
- 作为库的开发者,注意是否声明接收
std::initailizer_list的构造函数,但是尽量让小括号大括号不影响结果 - 作为库的使用者,默认使用花括号
{}初始化,除非:
- 需要调用非
initializer_list构造函数 - 可能引起构造函数选择混乱
避免使用
=初始化,容易混淆赋值与初始化语义
- 对于模板的开发,使用接收参数创建对象是使用()还是{},其实开发者并不知道,只有调用者才知道,需要把控制权给开发者,比如给出两个不同的模板或标签
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而非0和NULL
nullptr 的类型特性
一句话:空指针就是nullptr
- 类型:
std::nullptr_t。 - 能隐式转换为任意指针类型。
- 不能转换为整数类型,因此不会误选
int重载版本。
考虑nullptr原因
- 0 和 NULL 本质是整型,不是指针,会导致重载决议中的意外,使用
nullptr能避免且能提升代码的清晰性,有更明确的语义
```,尤其在涉及`auto`变量时
```cpp
void foo(int* ptr) {}
void foo(int val) {}
foo(NULL); // 编译错误:重载歧义(可能是int*或int)
- 在模板中,
NULL和0会被推导为int,无法隐式转换为指针类型
9. 优先考虑别名声明using而非typedef
- 别名模板:为复杂的模板类型定义简洁的别名,给类型模板“取别名”
而typedef实现:template<class T> using MyAllocList = std::list<T,MyAlloc<T>>;这是属于嵌套从属类型,在模板中使用: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
- 枚举本身就是类型,所以将枚举值传入模板中,推导出来的就是枚举类型,如
enum Color{...};Color c = red;,c的类型就是Color - 未限域enum:
enum Color;限域enum:enum class Color - 非限域/限域
enum都支持底层类型说明语法,限域enum底层类型默认是int。非限域enum没有默认底层类型,可通过定义时显式指定:enum class Color : int{...};指定其底层为int - 未限域enum指枚举量的名字泄露到枚举类别所在作用域,如
red可直接被使用 - 未限域enum枚举量可以隐式转换到其他类型,比如整形、浮点型:
Color c = red;if(c<2.4){...}能直接通过编译;而enum class拥有更强的类型,对于这些转换都需要显示转换,更加合理 - 默认未限域enum不能前置声明,因为底层类型还需要内部值的范围推导出来,导致如果内部元素更改,就需要重新编译整个文件;而限域enum可以前置声明,因为其默认底层类型是
int;当然如果使用enum color:int;是可以前置声明的 - 未限域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函数而非使用未定义的私有声明
- 格式:
函数声明 = delete;,删除函数一般放置于public,因为cpp会先校验可访问性,再校验删除状态 - 删除函数适用于所有函数,不仅仅包括成员函数,还有普通函数、模板函数
- 删除成员函数,是为了阻止编译器生成默认函数,比如默认构造函数等
- 删除普通函数,是为了阻止不合理的隐式转换;因为虽然deleted函数不能被使用,但它们还是存在于你的程序中。也即是说,重载决议会考虑它们,发现他们被删除之后会直接报错。
bool isLucky(int number); //原始版本 bool isLucky(char) = delete; //拒绝char bool isLucky(bool) = delete; //拒绝bool bool isLucky(double) = delete; //拒绝float和double
- 提升(Promotion) 是隐式的、无损的转换(如
char→int,float→double),优先级高于 标准转换(Standard Conversion) (如int→long,float→int)- 这里删除
double拒绝了float,是因为float会优先提升到double
- 删除特例化模板函数,是为了阻止不该进行的模板具现,比如操作指针的模板,就不应该对
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声明重写函数
override、virtual以及final是仅声明时使用的关键字,与const、引用限定符(&,&&) 不一样,前者在类外实现是不允许再写此关键字,后者必须同时出现在类外定义中,否则函数签名不匹配,定义了一个新函数。
override是重写,overload是重载- 返回类型不影响
overload,但是影响override 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 &&)
override、final必须放置于函数声明最后,即const、引用饰词等之后;同时,可以允许自定义名为override的函数override允许安全使用 协变返回类型
class Base {
public:
virtual Base* clone();
};
class Derived : public Base {
public:
Derived* clone() override; // ✅ 合法协变返回
};
13. 优先考虑const_iterator而非iterator
- 只要不需要修改元素,就应优先使用
const_iterator。
const_iterator只保证不能通过它修改元素本身- 不限制操作容器结构(如插入、删除)
const_iterator获取:
- 可通过
const容器调用begin、end获取 - cpp11可通过成员方法
cbegin、cend - cpp14可通过非成员函数模板获取
cbegin、cend、crbegin、crend
cpp11可通过模板自己实现cbegin等方法
template<class T>
auto cbegin(const T& container)->decltype(container.begin()){
container.begin();
}
- 为了兼容原生数组和仅支持非成员函数的容器,如第三方库,优先使用非成员函数,但是主语cpp11需要手动提供
using std::cbegin;
using std::cend;
auto it = std::find(cbegin(container), cend(container), val);
- 一般代码中
- 用
cbegin()/cend()代替begin()/end(),只要不打算修改元素。 - 使用
auto推导,配合const_iterator更方便。
- 泛型模板代码中
- 用非成员版本的
cbegin()/cend()(using std::cbegin;)。 - 如使用 C++11,手动提供缺失的非成员版本。
14. 如果函数不抛出异常请使用 noexcept
补充:cpp异常处理
- cpp没有异常是搭配默认捕获的,都是需要手动写try-catch,如果异常未被捕获,程序会调用
std::terminate()终止- cpp的throw-catch类似于参数传递,不同之处在于throw之后局部变量都会被销毁,所以不能throw出局部变量的指针或引用,而且C++ 异常传递仅支持 派生类到基类 的转换(多态),且必须通过引用或指针传递。其他隐式转换(如
int→double)会被禁止- cpp的默认捕获是catch(...),能捕获所有异常,但是无法直接访问异常对象
- throw可以抛出
- 基本数据类型,如
int- 抛出标准库异常类,都继承自
std::exception,比如std::out_of_range("Out of range")- 自定义异常类,继承自
std::exception或其派生类
- C/C++存在很多的未定义行为,如果程序中使用了未定义行为,那么得到的结果是不可知的,可能会导致编译报错,也可能导致运行出错等,还可能无事发生,具体结果可能与平台/编译器有关
- 在标准库中,有些操作就会抛出错误(如
new),这些可以查询相关文档是能查到的- 栈展开是 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语法
- 说明符声明函数是否抛出异常
void func() noexcept; // 声明 func 不抛出任何异常
void func() noexcept(true); // 同上,显式指定不抛异常
void func() noexcept(false); // 声明 func 可能抛出异常
- 若标记为
noexcept的函数抛出异常,程序直接调用std::terminate()终止。
- 操作符
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
- 更安全的接口设计:
noexcept是函数接口的一部分,明确告知调用者「不会抛异常」。所以定义时不需要再指出 - 启用更多优化:
编译器对noexcept函数可以省略异常处理机制,生成更高效代码。 - 支持 STL 的强异常安全保证:
如std::vector::push_back、std::swap等需要知道操作是否会抛异常,才能决定是否使用移动操作。
哪些场景使用
- 明确不会抛异常的函数
- 移动构造、移动赋值以及swap函数
- 析构、delete操作符默认是noexcept
- 只有当其成员的析构函数为
noexcept(false)才不为noexcept
class Widget {
public:
~Widget() {
throw std::runtime_error("Oops"); // ❌ 触发 std::terminate()
}
};
int main() {
try { Widget w; }
catch (...) {} // 不会执行,程序直接终止
}
- 不要滥用noexcept,如果内部调用的函数会抛异常,就不能再声明noexcept;同时也不能故意隐藏错误,比如将错误捕获然后返回状态码
STL中的noexcept
- STL 会使用
std::move_if_noexcept()
- 所以对于自定义类,需要将移动构造赋值声明为noexcept,才能让容器在扩容时才会使用移动函数:
if (is_nothrow_move_constructible<T>::value || !is_copy_constructible<T>::value)
std::move(t); // 否则保守地使用复制
std::swap是否noexcept取决于你提供的类型T的swap是否noexcept:
template<typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b)));
异常与析构
- 尽管可以显示指定析构为
noexcept(false)(历史包袱),但是尽量不允许析构抛出异常,因为可能会导致同时抛出两个异常,进而导致程序结束
struct Bad {
~Bad() noexcept(false) { throw 42; } // ❌ 危险
};
void foo() {
Bad b;
throw std::runtime_error("oops"); // 触发栈展开时析构 b,导致 terminate
}
15. 尽可能使用 constexpr
constexpr作用
- 作用于对象,则代表该对象既是const,又是编译期常量
- 作用于函数,在符合规则的情况下,如果传入编译期常量,结果也会是编译期常量;否则在运行期计算。
语法规则
- 作用于对象,要求必须是编译期常量,所以并非所有
const对象都是constexpr
constexpr int size = 10;
std::array<int,size> arr; // ok
const int runtimeSize = getValue(); // const并不是constexpr
std::array<int,runtimeSize> arr; // 编译错误,指定数组大小必须编译期常量
- 作用于函数
- cpp11要求内部实现只能有一句、返回类型必须是字面量类型,同时作用于成员函数时默认是const的
字面量类型:
- 基本类型(除void)
- 自定义类型,但是构造函数声明了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成员,此时可能导致资源竞争
解决方案
- 单变量修改可以使用
std::atomic,开销更小 - 多变量就得使用
mutable
使用互斥量或原子操作会影响类的复制和移动特性(一般不可复制不可移动)
17. 理解特殊成员函数的生成
什么是特殊成员函数?
特殊成员函数是编译器在特定条件下自动生成的成员函数,主要包括:
- 默认构造函数(当没有其他构造函数时)
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
C++11新增的两个移动操作:
- 移动构造函数
- 移动赋值运算符
关键点:只有当代码需要使用这些函数但开发者没有显式声明时,编译器才会自动生成它们。
生成规则详解
- 基本特性:
- 所有自动生成的函数都是
public且inline的 - 移动操作会智能处理成员:对可移动的成员执行移动,对基本类型等不可移动的成员执行拷贝
- 大三法则(Rule of Three) :
- 如果你显式声明了拷贝构造、拷贝赋值或析构函数中的任意一个,就应该同时声明这三个
- 原因:声明其中任何一个通常意味着类需要管理资源,因此需要完整的资源管理逻辑
- 移动操作的特殊规则:
- 两个移动操作相互影响:声明其中一个会导致另一个不会被自动生成
- 移动操作只在类中没有用户声明的拷贝操作、移动操作和析构函数时才会生成
重要结论
- 移动操作的生成条件最严格:
- 需要类中没有任何用户声明的拷贝操作、移动操作和析构函数
- 析构函数的特殊变化:
- 在C++11后,默认生成的析构函数是
noexcept的
- 拷贝操作的隐藏规则:
- 如果声明了移动操作,拷贝操作会被隐式删除(除非显式声明)
实际建议:对于资源管理类,要么遵循大三法则显式实现所有拷贝操作和析构函数,要么使用
=default明确使用编译器生成的版本。