Loading

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 的推导会综合 TFoo 二者考虑。

分几种情形考虑:

  • Foo 是一个指针/引用,但不是万能引用,规则如下:

    1. ⭐ 若 foo 表达式类型是引用,忽略引用。
    2. foo 的类型与 Foo 的类型进行模式匹配,以推导 T对,就是你想的那个字符串模式匹配,一个萝卜一个坑
    案例 1:FooT&
    T Foo 定义 / 传入
    int int& int foo = 1
    const int const int& const int foo = 1
    const int const int& const int& foo = bar
    案例 2:Fooconst T&
    T Foo 定义 / 传入
    int const int& int foo = 1
    int const int& const int foo = 1
    int const int& const int& foo = bar
    案例 3:FooT*
    T Foo 定义 / 传入
    int int* &bar
    const int const int* const int* foo = &bar
  • Foo 是一个万能引用,即 T&&,规则有些特别:

    1. ⭐ 若 foo 是左值,则 T 被推导为左值引用。注意 p/effective-modern-cpp.html#左值与右值 的例子。
    2. foo 是右值,则按照上一情形。
    案例 4:FooT&&
    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:FooT
    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",不过并不难,这里还是学了一下。
  1. 如果 FooT*,那么传入数组时将会退化为指针。

  2. 如果 FooT&,那么传入数组将会得到 T(&)[]

    这个特性伴随着一个计算常量数组大小的函数模板
    template <class T, std::size_t N>
    constexpr std::size_t arraySize(T(&)[N]) noexcept {
    	return N;
    }
    
    为什么不试试同样编译时并且更短的sizeof(arr) / sizeof(arr[0])呢?

    image

  3. 函数与上述情形类似,如传入 void(int, int) 时, FooT* 的情况下是 void(*)(int, int);而 FooT& 的情况下是 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

和上面类似,大多数情况都是能正常使用的。例外是,现要求实现一个 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 xdecltype(x) 返回 intdecltype((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;
}

image

如此,我们就知道 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 容器的 cbeginrbegin 以及 crbegin 只能这样做:

template <class It>
decltype(auto) cBegin(const It &it) {
    return std::begin(it);
}

如果需要 rbegin 而不是 crbegin 则需要删除 const 限定,并使用 std::reverse_iteratorstd::end() 构造。

在 C++14 中,则有对应的工具:std::{,c}{,r}{begin, end}()

条款十四:若函数不抛出异常,使用 noexcept

这可以为代码生成带来更多的便利,便于编译器优化你的代码。

推荐如下场景中使用 noexcept

  1. 析构器。默认情况下,析构器自带 noexcept:可以使用 static_assert(noexcept(X{}.~X()), "no") 来检验。
  2. 移动构造。
  3. 移动 operator=
  4. 叶子函数:在函数内部不分配栈空间,也不调用其它函数,也不存储非易失性寄存器,也不处理异常的函数[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,总之其要义为能 constexprconstexpr。这样能以编译花费置换运行花费。

条款十六:保证 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() 可以返回一个管理 *thisstd::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 惯用法前,保证遵守三五法则


  1. 待更新 ↩︎

  2. image ↩︎

  3. https://stackoverflow.com/questions/81870/is-it-possible-to-print-a-variables-type-in-standard-c#answer-20170989 ↩︎

  4. https://en.wikipedia.org/wiki/Most_vexing_parse ↩︎

  5. https://scottmeyers.blogspot.com/2016/11/help-me-sort-out-meaning-of-as.html ↩︎

  6. image ↩︎

  7. https://gcc.gnu.org/onlinedocs/gccint/Leaf-Functions.html ↩︎

  8. image ↩︎

  9. https://stackoverflow.com/questions/41871115/why-would-i-stdmove-an-stdshared-ptr ↩︎

posted @ 2023-04-23 10:35  PatrickyTau  阅读(201)  评论(0编辑  收藏  举报