简单资料参考

课本可以当作入门了解编程知识,但一般书的代码不符合现代C++的编程风格和设计,所以质量不佳,不建议模仿。至于比较好的C++学习资料,可以参考The Definitive C++ Book Guide and List
除了这些,在有了一定基础后,最重要的C++参考还是cppreference,如果觉得访问较慢,可以在首页下方下载离线版本。

进阶

有时候这语言让你很喜欢,有时候你只想开骂。不过大部分时候是开骂。Pardon my French, 玛德这个傻逼语言。 ——匿名

C++11到C++17提供了各种丰富的语言特性,正确使用下可以避免写出可读性差而难以维护的代码。下面介绍一些C++编程中一些重要的,但是课堂通常不会涉及较多的特性或者说概念。

Stanrd Library

C++和其他很多语言同样,有自己的标准库。学会如何正确使用标准库,而不是再造轮子也是非常重要的。其中Standard Template Library(标准模板库STL)提供了算法和容器的模板。这些标准库的内容可以cppreference中查到。

未定义行为

未定义行为(undefined behavior,UB)——对程序的行为无任何限制。未定义行为的例子是数组边界外的内存访问,有符号整数溢出,空指针的解引用,在表达式中对同一标量多于一次的中间无序列点 (C++11 前)无序 (C++11 起)的修改,通过不同类型的指针访问对象,等等。不要求编译器诊断未定义行为(尽管许多简单情形确实会得到诊断),而且不要求所编译的程序做任何有意义的事。 ——cpp reference

未定义行为下,程序可以做出任何举动。在写代码的时候应该要考虑到当前的行为是不是未定义,即使运行正确,也只是一种巧合。常见新手写出来的未定义行为是有符号整数的一些操作,比如移位: C++20前:> 对于有符号的非负 a,若 a * 2b能以返回类型的无符号版本表示,则将该值转换到有符号后即是 a << b 的值(这使得以 1<<31 创建 INT_MIN 合法);否则行为未定义。

对于负 a,a << b 的行为未定义。 对于无符号 a 和有符号的非负 a,a >> b 的值是 a/2b 的整数部分。 对于负 a,a >> b 的值是实现定义的(大多数平台上进行算术右移,故结果保留为负)。 ——cpp reference

通常情况下编译器会在静态分析时给未定义行为抛出警告(ReSharper也是),注意这些警告能避免这些问题。

我们应该感谢你的未定义程序运行时没有把你的硬盘格式化或者毁灭地球 ——匿名

类似的行为还有实现定义的行为,指这个行为由编译器内部实现决定。比如CHAR_BIT的大小(即一字节的位数),尽管通常来说一个字节代表8位,但是这个数值标准中并没有给出定义,而是编译器实现的。
~~

命名空间

不少人在代码开始的时候都是using nzamespace std;,初学编程的人可能没法意识到这个是什么意思,实际上是代表使用std(标准库)的命名空间。
命名空间,顾名思义,这是一个有名字的空间,是为了解决命名冲突的问题。想想你的项目越来越大,创建类越来越多,为了保证命名有意义且方便使用,就不可避免长命名。
class i_don't_know_how_to_name_this_but_it_does_exist;
而命名空间的作用,像是把命名分割一样。
代码在命名空间project::utility下定义了一个类tree。
在无命名空间下使用
project::utility::tree my_tree;
namespacce project下使用

  1. namespace project
  2. {
  3. void foo()
  4. {
  5. utility::tree my_tree;
  6. ...
  7. }
  8. }

通过命名空间使用声明

  1. using namespace project::utility;
  2. tree my_tree;

那么回到一开始的using namespace std;这条语句,实际上是直接使用std命名空间中东西,而且不需要加前缀std::。在小项目中可能不会产生什么后果,而如果在大型项目中有可能定义了和std命名空间中同名的类、函数或者常量,在使用时不明确指代,这就是命名空间污染。
在工程实践中,一般会把多个类定义分到不同的文件中,并把不同命名空间放到不同的文件夹中,以保持项目有序。
~~

常量表达式

这是C++与其他语言区别的一个重要特性,常量表达式和常量不同,常量可能是运行时执行初始化,而常量表达式要求值能在编译时求出来。这给了我们新的方式定义常量,不是
#define PI 3.1415926535
而是
constexpr auto pi = 3.1415926535;
这个关键词也可以用在函数修饰符上,此时这个函数既可以在编译时运行,也能用在运行时,取决于使用情况。

  1. constexpr auto f(const int i){ return i + 1; }
  2. ...
  3. std::cin >> i;
  4. //constexpr auto my_value = f(i); ERROR, because i is not constexpr
  5. constexpr auto my_compile_time_value = f(0);
  6. const auto my_run_time_value = f(i);

常量表达式除了可以是基础类型的编译时常量,constexpr修饰的函数或成员函数,也可以是其他符合一定条件的类型,即class或者struct定义的类型。这个特性可以使得一些代码仅在编译时施行,而在运行时不消耗时间,起到很好的优化作用。

右值

在介绍概念之前,先来看这个问题。

  1. std::vector<int> foo();
  2. std::vector<int> vec;
  3. ...
  4. vec = foo();

在C++11之前,这段代码是要运行一次复制构造函数的,vec将函数的对象复制一遍,最后函数返回的对象被析构。知乎上的回答是这么描述这个问题的:

如何将大象从一台冰箱转移到另一台冰箱? 普通解答:打开冰箱门,取出大象,关上冰箱门,打开另一台冰箱门,放进大象,关上冰箱门。 2B解答:在第二个冰箱中启动量子复制系统,克隆一只完全相同的大象,然后启动高能激光将第一个冰箱内的大象气化消失。

在C++11移动语义加入下,这段代码实际上内部就仅需要进行一次移动赋值函数vector& operator=(vector&&)即可。移动语义就是为了解决右值产生的复制问题。

Quoting n3055:

  • An lvalue (so-called, historically, because lvalues could appear on the left-hand side of an assignment expression) designates a function or an object.
  • An xvalue (an “eXpiring” value) also refers to an object, usually near the end of its lifetime (so that its resources may be moved, for example). An xvalue is the result of certain kinds of expressions involving rvalue references.
  • An rvalue (so-called, historically, because rvalues could appear on the right-hand side of an assignment expression) is an xvalue, a temporary object or subobject thereof, or a value that is not associated with an object.

——StackOverflow: What are rvalues, lvalues, xvalues, glvalues, and prvalues?

概念可以简单看看,简单来说右值就是当前代码一些“没有名字”但又确实存在的值,比如一个函数或者成员函数调用返回的临时值(如上述),这些值如果放置不管会在下一条语句析构。你也许已经明白上述代码中出现的&&其实就是右值类型,C++中以这个符号来表示右值。它也可以定义一个右值变量auto&& vec = foo();,如果右值不是基础类型,只做临时使用并且需要修改时,应该使用这种形式,此情况下甚至不需要调用构造函数。具体内容可以查看引用初始化
在类的实现是就要考虑到是否实现移动构造和移动赋值。至于什么时候需要实现,可以参考三/五/零之法则。通常来讲,只要实现复制构造函数,那么其他四个函数(移动构造、复制赋值、移动赋值)都要实现。
有了右值到左值,你可能有时也需要从左值到右值的转换。

  1. std::vector<my_class> vec;
  2. ...
  3. {
  4. my_class temp;
  5. // ... some operations to the temp;
  6. vec.push_back(temp);
  7. }

上述代码依然会产生多余复制的问题,恰好vector的push_back函数提供了右值重载,可以帮助我们优化。这时就需要std::move,将temp转成右值:vec.push_back(std::move(temp));
另一个与移动语义相关的函数就是std::forward<T>,有关它的介绍可以在这篇知乎回答中看到。涉及到的知识较多,这里不再引入。

模板

如果想直接了解模板的强大,并且不怕陡峭的学习曲线的话,可以直接看油管的CppCon 2016: Arthur O’Dwyer “Template Normal Programming (part 1 of 2),而不需要看下面的内容。
C++的泛型,或者更准确说,模板,与其他语言不同的是,它是在编译时处理的,可以理解成一个名字叫template的编译时执行函数,<>内是它的参数。

模板是定义下列之一的 C++ 实体:

  • 一族类(类模板),可以是嵌套类
  • 一族函数(函数模板),可以是成员函数
  • 一族类型的别名(别名模板)(C++11 起)
  • 一族变量(变量模板)(C++14 起)
  • 概念(制约与概念)(C++20 起)

——cpp reference

深入浅出的基础

如何深入浅出通俗易懂地介绍一下c++ 里的Template Metaprogramming(TMP)? 都用到template了 还想浅出 ——知乎某问题下的回答

模板定义的事物,首先它是一个模板,而非其他,直到你给模板输入参数后实例化后,它才能看作是其他。

  1. template<typename T>
  2. void foo()
  3. {
  4. ...
  5. }
  6. ...
  7. // foo is a template
  8. foo<int>();
  9. // now foo<int> is a function

编译器会为所有需要实例化的模板生成定义,只有完全特化(后面会提到这个概念)的模板函数定义可以写在源代码文件(.cpp),其他模板应该写在头文件(.h),否则会抛出未定义的错误。
不同于其他用语言,模板的参数既可以是类型,也可以是模板,或者是非类型。

非类型模板形参必须拥有结构化类型,它是下列类型之一(可选地有 cv 限定,忽略限定符):

  • (到对象或函数的)左值引用类型;
  • 整数类型;
  • (指向对象或函数的)指针类型;
  • (指向成员对象或成员函数的)成员指针类型;
  • 枚举类型;
  • std::nullptr_t ;(C++11)
  • 浮点类型;(C++20)
  • 拥有下列属性的字面类类型:(C++20 )
    • 所有基类与非静态数据成员为公开且非 mutable
    • 所有基类与非静态数据成员的类型均为结构化类型或其(可能多维的)数组。

这里的字面类型简单理解就是能构造出编译时常量的类型。

  1. template<auto Value>
  2. constexpr auto just_one_more = Value + 1;
  3. class my_literal_type
  4. {
  5. public:
  6. int int_value;
  7. }
  8. // C++20
  9. template<my_literal_type Value>
  10. constexpr auto my_literal_type_v = Value.int_value;

模板给了我们在编译时操作类型的方式

别名模板

template < 模板形参列表 > using 标识符 attr(可选) = 类型标识 ; ——cpp reference

虽然typedef在绝大情况下都和using作用相同,但是using有typedef无法替代的特性,这条就是其中之一(实际上C++中应该使用using而不是typedef,using的用法更友好易懂)。

  1. template<typename T>
  2. using reference_type = T&

用法简单,这里不多赘述。

变量模板

  1. template<typename T>
  2. constexpr T pi = T(3.1415926535897932385);
  3. ...
  4. constexpr auto float_pi = pi<float>;

也很简单,不多赘述。

特化

这是C++模板的一项重要特性,来看一个非常常用的模式代码就明白了:

  1. template<typename T>
  2. struct is_void{ constexpr auto value = false; }
  3. template<>
  4. struct is_void<void>{ constexpr auto value = true; }
  5. template<typename T>
  6. inline constexpr auto is_void_v = is_void<T>::value;

这是标准库中头文件is_void的简单实现。顾名思义,这个是用来判断当前的T类型是否为void的类。你应该会注意到我似乎定义了两个同名的类型,但实际上第二个是对第一个主模板的全特化定义。全特化可以重新定义在特定条件下的主模板,以template<>来标注,在模板参数写在模板名后面。

以下任何一项均可以完全特化: 1.函数模板 2.类模板 3.(C++14 起)变量模板 4.类模板的成员函数 5.类模板的静态数据成员 6.类模板的成员类 7.类模板的成员枚举 8.类或类模板的成员类模板

9.类或类模板的成员函数模板 ——cpp reference

在一些场景中这项特性十分实用。假设你有很多个组件类,创造这些组件需要各自的信息,那么每次创造组件的时候都要创建并填写相应的信息类。
image.png
vulkan api中的部分源码
这样会使得你的代码到处充满了这些信息类的名称,为了简化名称以提高代码可读性,你可能希望调用时所有信息类都能有个统一的名称:

  1. info_t<Component> info;
  2. ...
  3. component.initialize(info);

当给模板传入component类型的时候,模板返回一个对应的info类型,那么就可以这么做:

  1. template<typename ComponentType>
  2. struct info;
  3. template<>
  4. struct info<VKFence>{ using type = VKFenceCreateInfo; }
  5. template<>
  6. struct info<VKDevice>{ using type = VKDeviceCreateInfo; }
  7. ...
  8. template<typename ComponentType>
  9. using info_t = info<ComponentType>::type;

因为别名模板无法使用特化,所以我们使用了类模板来辅助我们实现。
另一种特化称为偏特化,也就是不完全特化,偏特化的结果依然是模板,只能在类上使用。标准库中的偏特化例子为unique_ptr,提供了对数组的特化版本。

  1. template<class T, class Deleter = std::default_delete<T>>
  2. class unique_ptr;
  3. template <class T, class Deleter>
  4. class unique_ptr<T[], Deleter>;

其他重要参考