Effective Modern C++ 学习笔记
闲话
今天是 2023 年 4 月 23 日,俺开始正式学习面试相关内容了。打算先从 Effective Modern C++ 这本书开始学起,作为日后代码风格、习惯的指导。不过俺没有一起学习的小伙伴,qwq。
与 ICPC 切割之后,内心都轻松了许多。小醉一宿之后还是十分愉悦的。
欢迎加入 C++ 学习群:https://jq.qq.com/?_wv=1027&k=TW7t8caN
我在微云中上传了 Effective Modern C++ 英文版 pdf:https://share.weiyun.com/nO38Xq2l
中文 mdBook: https://cntransgroup.github.io/EffectiveModernCppChinese/,翻译得十分不错,在我学习过程中没有太多问题虽没有遇到太多表述问题,但存在许多事实错误。 [1]。
- upd: 翻译组 repo 更新不勤,现已完全不建议看这个了。
作者的勘误网站:https://www.aristeia.com/BookErrata/emc++-errata.html,说起来这本书年代有些久远,编程更迭地很快,因此需要配合这个网站来看。
这书从 \(42\) 个条款item向读者展现了「更高效的 C++」,在列叔叔的入门博客。对我这样的 C++ 初学者十分友好。
引言部分
⭐ 左值与右值
通常,判断一个表达式是否为左值的小技巧:是否能取得其地址。
一个表达式的类型与它是否是左值、右值无关。即使函数形参是右值引用类型(void func(Foo &&foo) { }
),它也依然是左值。
第一章:类型推导
条款一:理解模板的类型推导
模板函数 template <class T> void func(Foo foo) { }
中,Foo
也许不与 T
相同,例如 const T&
。在推导时,一般我们会希望传入的 foo
类型与 T
相同,这样 Foo
就能够正确处理。然而有时候 T
的推导会综合 T
和 Foo
二者考虑。
分几种情形考虑:
-
Foo
是一个指针/引用,但不是万能引用,规则如下:- ⭐ 若
foo
表达式类型是引用,忽略引用。 - 将
foo
的类型与Foo
的类型进行模式匹配,以推导T
。对,就是你想的那个字符串模式匹配,一个萝卜一个坑
案例 1:
Foo
为T&
T
Foo
定义 / 传入 int
int&
int foo = 1
const int
const int&
const int foo = 1
const int
const int&
const int& foo = bar
案例 2:
Foo
为const T&
T
Foo
定义 / 传入 int
const int&
int foo = 1
int
const int&
const int foo = 1
int
const int&
const int& foo = bar
案例 3:
Foo
为T*
T
Foo
定义 / 传入 int
int*
&bar
const int
const int*
const int* foo = &bar
- ⭐ 若
-
Foo
是一个万能引用,即T&&
,规则有些特别:- ⭐ 若
foo
是左值,则T
被推导为左值引用。注意 p/effective-modern-cpp.html#左值与右值 的例子。 - 若
foo
是右值,则按照上一情形。
案例 4:
Foo
为T&&
T
Foo
foo
定义 / 传入 int
int&&
右值 1
int&
int&
左值 int foo = 1
const int&
const int&
左值 const int foo = 1
const int&
const int&
左值 const int& foo = bar
- ⭐ 若
-
Foo
既不是指针,也不是引用,则忽略 cv 限定符、引用。案例 5:
Foo
为T
T
Foo
定义 / 传入 int
int
int foo = 1
int
int
const int foo = 1
int
int
const int& foo = bar
除了上面的几点之外,还存在数组/函数退化指针的情况。作者评价为 "This rarely makes any difference in practice",不过并不难,这里还是学了一下。
-
如果
Foo
是T*
,那么传入数组时将会退化为指针。 -
如果
Foo
是T&
,那么传入数组将会得到T(&)[]
。这个特性伴随着一个计算常量数组大小的函数模板
template <class T, std::size_t N> constexpr std::size_t arraySize(T(&)[N]) noexcept { return N; }
为什么不试试同样编译时并且更短的
sizeof(arr) / sizeof(arr[0])
呢? -
函数与上述情形类似,如传入
void(int, int)
时,Foo
为T*
的情况下是void(*)(int, int)
;而Foo
为T&
的情况下是void(&)(int, int)
。
条款二:理解 auto 类型推导
与条款一中的对 Foo
的推导规则是一致的,例外发生在统一初始化的时候,即:
auto foo = { 1, };
auto bar { 1 };
书上说,这俩的类型都是 std::initialize_list 然而实际上,在提案 N3922 之后,后者是 int
。这一点上面提供的 pdf 中没有提到(不过作者在网站上勘误[2]了)。笔者给翻译组提了个 issue。不懂,翻译组不写代码的吗?
C++14 中,强化了 auto
,使其可以作为函数返回类型的占位符使用。需要注意,下面的代码无法推导:
auto foo() {
return {1, 1, 4, 5, 1, 4};
}
这时候就需要你手动标出类型了。
⭐ 条款三:理解 decltype
- 这一部分写得十分啰嗦,建议直接看叔叔写的:https://zclll.com/index.php/cpp/decltype_auto.html。
和上面类似,大多数情况都是能正常使用的。例外是,现要求实现一个 access
函数,将参数按照原本的值类别转发出来。
完美转发,直接将返回值设定为 auto
:
template <class T>
auto f(T&& foo) {
return std::forward<T>(foo);
}
然而根据条款一,引用会被忽略,不行。把返回值设置成万能引用能够做到这件事吗?但这样没法处理非引用类型的传入。
这时候就需要 decltype(auto)
了(C++14),这能够推导出带引用类别的值的类型。
template <class T>
decltype(auto) f(T&& foo) {
return std::forward<T>(foo);
}
如果是 C++11 的话,则需要手动将 decltype(auto)
展开成 decltype(std::forward<T>(foo))
,并使用后置返回:
template <class T>
auto f(T&& foo) -> decltype(std::forward<T>(foo)) {
return std::forward<T>(foo);
}
⭐ 特别注意对于 int x
, decltype(x)
返回 int
而 decltype((x))
返回 int&
。
条款四:懂得如何查看推导类型
可以用叔叔博客里提到的技巧:查看报错信息。
template <class T> void dumpType();
使用这样一个没有 Body 的函数必然报错,它同时反馈了传入的类型。
使用示例
template <class T>
void dumpType();
int main() {
int x = 0;
dumpType<decltype(x)>();
dumpType<decltype((x))>();
return 0;
}
如此,我们就知道 decltype(x)
是 int
,而 decltype((x))
是 int&
了。
当然,也有一个编译期、不报错的做法[3]:
展开代码
template <class T>
constexpr std::string_view type_name() {
using std::string_view;
#ifdef __clang__
string_view p = __PRETTY_FUNCTION__;
return string_view(p.data() + 34, p.size() - 34 - 1);
#elif defined(__GNUC__)
string_view p = __PRETTY_FUNCTION__;
#if __cplusplus < 201402
return string_view(p.data() + 36, p.size() - 36 - 1);
#else
return string_view(p.data() + 49, p.find(';', 49) - 49);
#endif
#elif defined(_MSC_VER)
string_view p = __FUNCSIG__;
return string_view(p.data() + 84, p.size() - 84 - 7);
#endif
}
第二章:auto
条款五:优先考虑使用 auto 代替显式类型声明
这一节没什么可说的,就是尽可能用 auto
代替显示类型声明,这样拓展性也好,编码也方便。唯一吸引我的是最初给出的 Demo:
template<typename It> // algorithm to dwim ("do what I mean")
void dwim(It b, It e){ // for all elements in range from b to e
while (b != e) {
typename std::iterator_traits<It>::value_type
currValue = *b;
b++;
...
}
}
没错,这里包含一个 dependent names(待决名) 的细节: typename
消歧义。
条款六:auto 推错了,就显式类型声明
一句话总结就是,不可见的代理类通常不适用于 auto
。如 std::vector<bool>
。
或者,你可以对其强制转换推导出正确的结果,不过这就意义不大了。
第三章:现代 C++
条款七:区别用 () 或 {} 初始化
零初始化,即如下三种:
static T object;
T();
T t = {};
T{};
CharT array[n] = "anything";
需要注意的只有 T t();
或被辨认为函数的声明[4],为了避免如此的歧义,建议使用零初始化。
非 static
数据成员可以使用 =
或 {}
给予默认值,而 ()
无此作用。
⭐ 不可复制的对象只能使用这种方式初始化(而不是带等号),如 std::atomic
。
另一方面,{}
初始化还伴随着窄化的报错(原文描述为不能通过编译,实际上是否报错由编译器决定):
double x = 0., y = 0.;
int sum { x + y };
这是 ()
初始化不具有的,当然拷贝初始化也不具有这一优势。
⭐ 在使用构造函数时,()
和 {}
的作用基本相同。但特殊的是,若包含 std::initializer_list
构造器,那么无论 {}
有多好的匹配,都会优先采用 std::initializer_list
这个匹配。这一点十分令人头疼。
边界情况:如果类包含 std::initializer_list
构造器传入 {}
会发生什么?
是空的 std::initializer_list
还是默认构造?答案是默认构造。如果想实现前者的效果,那么得 ({})
,特别注意 {{}}
使用了一个元素的 std::initializer_list
构造[5][6]。
条款八:尽量使用 nullptr 而不是 0 或 NULL
核心:不允许从 void*
到其他指针的隐式转换。
因此引入关键字 nullptr
来初始任何指针。其类型为 std::nullptr_t
,不会被模板识别为整型。
条款九:尽量使用 using 而不是 typedef
using
可以对模板类定义别名,而 typedef
不能。
通常 using
的语法也比 typedef
好理解,如:
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::string&);
依待决名,typedef
往往要写 typename xxx::type
来消歧义,而 using
则不用。
typename MyAllocList<T>::type list;
template <class T>
using MyAllocList = std::list<T, MyAlloc<T>>;
条款十:优先考虑使用枚举类
减少命名污染。
enum class Color { kBlack, kRed, kGreen };
默认情况下,普通枚举的类型是整型,而枚举类是强类型的。
另外,可以规定 enum
的底层类型,语法是 enum [class] EnumName : type;
。
但 enum class
也并非总是好用的,如对 std::tuple
使用 std::get
时,可能需要对 std::get
的实参加别名,这时候 constexpr
定义一套 enum class
倒不如直接 enum
。
条款十一:优先考虑使用 delete 删除函数而非声明为 private 且未定义
删除方法无法被友元调用,且任何函数都可定义为删除,而不止限于成员函数。
也可以对实例化的模板函数删除。
条款十二:重写派生类方法时标注 override
如果引用限定符不一样会给报错信息。
书中这里还介绍了一下引用限定,这将会在调用时区分被什么对象(*this
)调用。
class Widget {
public:
using DataType = std::vector<double>;
…
DataType& data() & { return values; }
DataType data() && { return std::move(values); }
…
private:
DataType values;
};
这样,在使用 auto foo = bar.data()
与 auto foo = makeWidget().data()
就分别调用了左值和右值的版本。
条款十三:如果不带修改,优先考虑 const_iterator 而不是 iterator
要避免,误操作而致使内容发生了改变。
然而 C++11 时,定义非 STL 容器的 cbegin
,rbegin
以及 crbegin
只能这样做:
template <class It>
decltype(auto) cBegin(const It &it) {
return std::begin(it);
}
如果需要 rbegin
而不是 crbegin
则需要删除 const
限定,并使用 std::reverse_iterator
从 std::end()
构造。
在 C++14 中,则有对应的工具:std::{,c}{,r}{begin, end}()
。
条款十四:若函数不抛出异常,使用 noexcept
这可以为代码生成带来更多的便利,便于编译器优化你的代码。
推荐如下场景中使用 noexcept
:
- 析构器。默认情况下,析构器自带
noexcept
:可以使用static_assert(noexcept(X{}.~X()), "no")
来检验。 - 移动构造。
- 移动
operator=
。 - 叶子函数:在函数内部不分配栈空间,也不调用其它函数,也不存储非易失性寄存器,也不处理异常的函数[7]。
noexcept
is particularly valuable for the move operations, swap, memory deallocation functions, and destructors.
这也是一种接口规范,是否抛出异常可能取决于对其他函数的调用/依赖(可以认为是调用此函数的前提),如:
template <class T, size_t N>
void swap(T (&a)[N],
T (&b)[N]) noexcept(noexcept(swap(*a, *b)));
条款十五:只要可能,就使用 constexpr
constexpr
一定包含 const
属性,但反之则不一定。如:
int x;
constexpr int kX = x; // error
const int kX = x; // const copy of x, fine
在编译期,若某个 constexpr
函数内部的信息都已知,则此函数的结果也会在编译期计算出来。如果有任何运行时决定的东西,则此 constexpr
函数的行为同普通函数。
在 C++11 时,constexpr
的函数只能包含一句 return
,这意味着可以通过三目运算符或者递归。C++14 之后不再受这个限制了。
下面还举了其他的例子,例如类的 getter, setter 也可定义为 constexpr,总之其要义为能 constexpr
就 constexpr
。这样能以编译花费置换运行花费。
条款十六:保证 const 成员函数的线程安全
典中典,要保证线程安全,上一把锁。C++ 中的做法是 std::mutex
。注意书中说这个是 move-only type 实际上是错误的,std::mutex
既不可移动也不可复制 [8]。
案例
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const {
std::lock_guard<std::mutex> g(m);
if(!rootsAreVaild) {
...
rootAreVaild = true;
}
return rootVals;
}
private:
mutable std::mutex m;
mutable bool rootAreValid { false };
mutable RootsType rootVals {};
}
条款十七:三五法则
编译器或许会帮你生成一些特殊的函数。
默认构造器、复制构造、复制赋值、移动构造、移动赋值。若复制构造、复制赋值其中之一未声明,编译器能够补全另一;但移动构造和移动赋值则不会,这是由于若类的任一成员无法进行移动构造,则整个类的移动赋值也无效,反之亦然。
另一方面,若显示声明了复制操作,那么编译器就不会生成移动操作。原因也很直观:若逐成员复制操作并不适用,那么逐成员移动操作应该也不适用。
在多态基类中的体现是析构器应当定义为虚函数:
class Base {
public:
Base() = default;
Base(const Base &) = default;
Base &operator=(const Base &) = default;
Base(Base &&) = default;
Base &operator=(Base &&) = default;
virtual ~Base() = default;
};
成员函数模板步抑制特殊成员函数的生成,但会有一些风险,条款二十三再论。
第四章:智能指针
原始指针有许多弊端,智能指针的出现一定程度上解决了这些问题。
条款十八:对于独占资源,使用 unique_ptr
一个简要的 std::unique_ptr
使用教程
用起来和普通指针几乎没啥区别(尤其是大小),但需要注意其可移动但不可复制。使用 operator=
的语义是移动。
#include <iostream>
#include <memory>
int main() {
auto p = std::make_unique<int>(3);
// std::unique_ptr<int> p{new int{3}};
*p = 2;
std::cout << *p << '\n';
static_assert(sizeof(p) == sizeof(int*));
return 0;
}
emc++
书中给了一个例子,可惜我没能完整重现一遍。但需要关注其中的:
auto delInvmt = [](Investment* pInvestment) {
makeLogEntry(pInvestment);
delete pInvestment;
};
std::unique_ptr<Investment, decltype(delInvmt)>
pInv(nullptr, delInvmt);
这个例子中,std::unique_ptr
的删除器是一个 lambda 而不是函数指针。否则 std::unique_ptr
的大小将会翻倍。
demo
#include <cassert>
#include <iostream>
#include <memory>
#include <utility>
struct Base {
virtual ~Base() = default;
};
struct Derive1 : Base {
int foo;
Derive1(int _foo) : foo(_foo) { std::cout << "Derive1::Derive1(int)\n"; }
};
struct Derive2 : Base {
int foo;
Derive2(int _foo) : foo(_foo) { std::cout << "Derive2::Derive2(int)\n"; }
};
bool need_1 = true;
auto delBase = [](Base *pBase) { delete pBase; };
void delBaseFunction(Base *base) { delete base; }
template <typename... Ts>
auto make_unique_base(Ts &&... ts) {
std::unique_ptr<Base, decltype(delBase)> ptr{nullptr, delBase};
if (need_1) {
ptr.reset(new Derive1(std::forward<Ts>(ts)...));
} else {
ptr.reset(new Derive2(std::forward<Ts>(ts)...));
}
return ptr;
}
int main() {
auto pBase1 = make_unique_base(1);
need_1 = false;
auto pBase2 = make_unique_base(2);
std::unique_ptr<Base, void (*)(Base *)> pBase3{nullptr, delBaseFunction};
static_assert(sizeof(Base *) == sizeof(pBase1));
static_assert(sizeof(Base *) * 2 == sizeof(pBase3));
return 0;
}
条款十九:对于共享资源,使用 shared_ptr
首先,std::unique_ptr
可以直接使用 operator=
转换为 std::shared_ptr
。
std::shared_ptr
为确保当前的是最后一个指向资源的,其实现使用了引用计数。多数实现中,其具有两倍于资源指针的大小。
此外,引用计数的内存必须动态分配,且对于引用计数的增减都必须为原子的。
删除器不是 std::shared_ptr
类型本身的一部分,而是每个实例的一部分。这是由于其可移动性。
同时,正是由于上一点,复制带来的引用计数花费是移动的三倍[9]。
比较逆天的是,如果使用 std::shared_ptr
去管理 this
指针的话,可能是有问题的。原理和多次基于原始指针构造 std::shared_ptr
是一样的(double free)。这种情况下,应使基类 Base
继承自 std::enable_shared_from_this<Base>
(CRTP),这个类包含一个成员方法 shared_from_this()
可以返回一个管理 *this
的 std::shared_ptr
。同时,为了防止存在一个指向对象的 std::shared_ptr
前先调用含有 shared_from_this()
的成员函数,这些基类往往将构造器声明为 private
,而只暴露出工厂函数用以创造 std::shared_ptr
的实例。
std::shared_ptr
没有自带的管理数组的一套,但完全可以使用 std::vector
, std::array
这类好工具。
条款二十:shared_ptr 可能悬垂时,使用 weak_ptr
条款二十一:优先考虑 make_xxx 函数而不是直接 new
条款二十二:使用 Pimpl 惯用法前,保证遵守三五法则
待更新 ↩︎
https://stackoverflow.com/questions/81870/is-it-possible-to-print-a-variables-type-in-standard-c#answer-20170989 ↩︎
https://scottmeyers.blogspot.com/2016/11/help-me-sort-out-meaning-of-as.html ↩︎
https://gcc.gnu.org/onlinedocs/gccint/Leaf-Functions.html ↩︎
https://stackoverflow.com/questions/41871115/why-would-i-stdmove-an-stdshared-ptr ↩︎