C++ 学习 —— 基本知识

Posted by 皮皮潘 on 08-19,2023

写在开头

最近因为项目中需要用到 C++,虽然 C++ 是我接触到的第一门静态语言,但是因为本科和研究生期间主要是在写 Java 和 Go,所以对于 C++ 的很多语言特性都已经记忆模糊了,只知道这门语言学习路线挺陡峭的,因此重拾起了 《C++ Primer》这个大部头入个门,对于 C++ 的一些基础语法重新进行了学习。不过说实话,在有了一定的其他语言的项目经验,再回头去学一门语言的时候,会发现这时候基础语法并不是重点,这门语言本身的设计哲学可能才是真正重要,需要细细品味和深入了解的,比如 Java 的 JVM + 面向对象 + 抽象思想,Go 的 CSP 模型 + 组合思想,而 C ++ 的话,则是其富有特色的,模板编程、面向过程、面向对象多种编程范式的结合了(吐槽一下,模板编程太复杂了,而且还有扩散性,也不能和虚函数一起用)。

这篇博客主要记录了一些再学习 C++ 语法过程中一些可能会令人困惑的地方,一些基础的语法则不再赘述。

constconstexpr 关键词

对于 const 关键词,一个令人困惑的地方在于 const和复合变量一起使用的时候,const 位置不同,它的含义不同:

  1. 顶层 const用来形容对应的变量是否是常值
  2. 底层 const用来形容复合变量(比如指针指向的对象)是否是常值

具体的判断方式就是,顶层 const比 底层 const以及复合变量标识符(* 和 &)更靠近变量,也就是说复合变量标识符右侧的是顶层 const,左侧的是底层 const

除此之外, const关键词的常用地方就是常量引用了,常量引用不需要对应的的变量的类型声明也是对应的常量引用,它可以被绑定到一个普通变量或者一个常量上,也就说我们可以将一个普通类型的可变变量,传给一个常量应用类型的变量(往往用在函数形参定义),不过在常量引用上只能调用常量成员函数,而不能调用任何会修改类内部信息的函数,在常量成员函数内部,编译器会将隐式的this指针声明为const ClassName* const类型,这意味着指针本身是常量,以及指针所指向的对象也是常量。虽然编译器会在编译时进行检查,但它并不能完全阻止在常量成员函数中修改对象状态。通过使用 mutable 关键字,可以在常量成员函数中修改被声明为 mutable 的成员变量。另外如果成员变量是一个指针,那么也可以修改指针指向的对象,因为编译器仅仅保证了类内部的内容不会修改(指针地址本身不会修改),但不会保证那些额外分配的内存中的内容不会修改,但是尽量还是不要修改,保证 const在逻辑上的常量。

另外一个可能会令人困惑的地方在于 constconstexpr 的区别,简单地来说,constexprconst 更加严格,被 constexpr修饰的东东(包括函数、常量等)需要可以在编译时求值,进而用于常量表达式的计算,而 const 则只是代表了一个值在并定义了之后就是常量,别人无法再修改这个值,但是这个值被定义的时刻可以是编译时也可以是运行时的。

全局变量与 static 关键词

我们先来看一下全局变量,如果两个源文件都定义了同名的全局变量,并且这两个变量都具有外部链接,那么就会出现链接错误。但如果这两个变量都具有内部链接,那么就不会出现链接错误,默认全局变量都是外部链接,除了一个例外:const全局变量如果是内置类型的话则可以在头文件中定义并初始化(这种情况下各个引入该头文件的源文件会内部链接该全局常量),反之则需要在头文件中使用 extern 声明,并且在源文件中定义,否则就会违反 ODR (One Define Rule 一次定义规则)。

这里解释一下内部链接和外部链接:

  1. 内部链接:如果一个对象有内部链接,那么它只在一个翻译单元中可见。翻译单元是指一个源代码文件,以及它所包含的所有头文件。static 全局变量具有内部链接。
  2. 外部链接:如果一个对象有外部链接,那么它在整个程序中都是可见的,而不仅仅是在一个翻译单元中。全局变量和函数默认具有外部链接。

一种简单地转换内部链接和外部链接的方式就是使用 static 关键词,但是在 C++ 中,static 关键字具有多种含义,具体取决于在哪里和如何使用它。这里,我们讨论 static 关键字在全局变量、局部变量和类成员变量中的作用。

  1. 全局变量: 当 static 关键字应用于全局变量时,它会改变变量的链接属性,从外部链接变为内部链接。
  2. 局部变量:当 static 关键字应用于局部变量时,它会改变变量的生命周期。static 局部变量在程序的生命周期内都存在。当函数再次被调用时,static 局部变量会保持其上一次的值。另外,由于在 c++11 以后,语法层面会保证静态局部变量的安全性,因此可以使用静态局部变量去实现单例模式,也即在单例方法中通过定义具体的静态局部实例变量来保证单例方法的原子性。
  3. 类成员变量:对于类的成员变量,static 关键字表示该变量在类的所有实例之间共享,可以使用 ClassName::StaticName 的方式访问与赋值(与 Java 类似)。不过记得在类中声明了对应的静态变量后,还要在源文件定义一次,才可以使用

函数

对于函数其实没啥好讲,主要时一些需要注意的点:

  1. 一定不要在函数中返回一个局部变量的引用,因为这会导致引用悬空,但是可以返回类成员变量的引用或者其他长生命周期变量的引用,也可以通过 this 返回对应的对象指针

  2. 如果函数声明返回一个值对象那么就是返回了一个纯右值,我们可以像纯右值一样看待与使用它。如果函数声明返回一个引用对象,那么就是返回了一个左值,此时我们可以看作有一个引用类型的临时变量绑定了对应的返回值,然后通过该临时变量进行后续的操作,比如继续绑定到一个声明的接收变量引用处

  3. NRVO 拷贝消除优化,在满足以下任一条件时,编译器将省略类对象的复制和移动构造,实现零复制的按值传递语义:

  4. 在 return 语句中,当操作数是一个与函数返回类型相同的类类型的纯右值时

  5. 在对象的初始化中,当初始化器表达式是一个与变量类型相同的类类型的纯右值时

  6. 当需要改变对应对象时,可以传入引用或者指针,如果对应的对象可能不存在并需要在函数中判断,则采用指针传参,反之则采用引用传参,对于返回值则尽量采用智能指针而不要是引用。另外由于在 C++ 编译器的底层 reference 往往以指针实现出来,因此 pass by reference 通常意味着真正传递的是指针,因此对于内置对象,pass by value 的性能会更好一点。

  7. 函数实参的拷贝,其底层实现是在栈上分配内存(由于栈上分配内存只需要按照对象大小修改 RSP 寄存器即可且不会有并发冲突,因此比内存分配快很多),然后按照对应对象的内存结构将对应的数据拷贝到栈上

右值引用

自 C11 开始,为了提升在某些对象传递场景下的性能效率,C 引入了右值引用的概念。

与左值引用相对应的,右值引用就是必须绑定到右值(纯右值+将亡值)的引用,它本质上仍然是引用,也就是某个对象的另一个名字,需要注意的是,虽然右值引用变量绑定到了一个右值,但是变量本身是一个左值(任何变量都是左值)。

在学习右值引用时,往往会碰到一个词——转移,包括 std 中提供的 API —— std::move 就是为了表明转移这个语义,但是转移并不是说你调用了 std::move 就真的发生转移了,转移中所谓的控制权转移本质上是通过自定义的、显示的代码实现的,其核心逻辑就是如果有指针对象就拷贝指针,并把源对象的指针置空,如果是值对象,则正常拷贝。具体的代码可以实现在任意一个拥有右值引用形参的函数中(移动构造函数,移动赋值函数或者带右值引用形参的函数)。

也就是说,往往与转移操作划等号的 std::move 方法其实不会做任何控制权转移的操作,它只是提供了将一个左值转化为将亡值的机制,进而让 C++ 自行通过最优匹配机制,找到并触发相应的拥有右值引用形参的函数,至于是否会进一步触发控制权转移,还得看对应的函数中是否实现了相应的机制,需要注意的是将亡值和纯右值都能触发拥有右值引用形参的函数,且右值引用形参优先级高于常量引用形参的优先级,另外需要注意,由于右值引用参数本身是左值,因此在具体的函数调用中,后续无法直接触发拥有右值引用形参的函数,如果要保持右值的语义,需要再次使用 std::move 转发为右值。

总的来说,移动函数和右值引用只是提供了一个调用到拥有右值引用形参的函数的契机,因为右值引用隐含了实参本身没有拥有者的语义(右值引用代表了在传参时它是一个右值),所以一般会在对应的函数实现中去做控制权转移的操作进而实现性能优化(移动代替拷贝),但由于这完全是自定义的行为,因此在拥有右值引用形参的函数中不实现任何控制权转移的操作也完全是可以的。

最后再来讨论一下,使用右值引用作为类别去接收一个函数返回的情况,由于函数返回的值往往是一个纯右值(除了返回引用类型的情况),因此在函数返回后,会先尝试触发 NRVO 拷贝消除优化,然后尝试触发移动构造或者赋值,最后才尝试触发拷贝构造或赋值 + 原对象的析构,但这里有一个例外情况,如果它被绑定到一个右值引用上,则不会产生任何拷贝或者赋值开销,且对应的返回变量的生命周期会延长到跟这个引用变量一样长(这是 C++ 的一个特殊规则:如果一个 prvalue 被绑定到一个右值引用上,它的生命周期则会延长到跟这个引用变量一样长)

容器

接下来,我们再来看看 C++ STL 中实现的常见容器。

首先,所有容器都具有的一个基本特性:它保存元素采用的是“值”(value)语义,也就是说,容器里存储的是元素的拷贝、副本,而不是引用。

对于容器的分类的话,我们可以这样去记忆:容器划分为顺序容器和关联容器,关键容器又进一步划分为有序容器和无序容器,需要注意的是,在关联容器中只有 map 和 unordered_map类重载了下标符号来快速获取和修改对应的值。

关于 C++ 的容器整体和 Java 其实差不大多,所以这里不再详细介绍了,之后有时间我会去阅读一下 《STL 源码刨析》一书,到时候再记录一篇博客,看一下 C++ STL 的容器底层的实现结构和高性能的原因。

面向对象编程

面向对象编程的设计原则是通用的:良好设计的 class 应该隐藏用户不需要看的部分,但是备妥用户需要的所有东西,并且具有足够的柔性以应对变更。

在 C++ 中的类定义,有继承访问控制符、成员访问控制符以及友元等概念,其具体含义如下:

  1. 继承中访问控制用来控制子类的调用者能否访问父类成员
  2. 成员访问控制符用来控制子类本身能否访问父类成员的
  3. 友元则是给一些没有继承关系的类以访问 protected, private 类型成员变量的能力

按照 《Effective C++》 一书中所说的,继承访问控制符、成员访问控制符、友元以及虚函数的使用可以从一定程度反映和表达出一个类设计者对于类的设计理念和想要表达的含义,但是一般来说,继承访问控制符用 public,成员访问控制符用 publicprivate,尽量不要用友元,就可以了,没有必要那么复杂。

C++ 对于继承的设计与 JAVA 一个很明显不同的地方在于,在 C++ 中子类只可以重写父类定义为虚函数的成员方法,对于并没有被父类定义为虚函数的方法,如果子类有相同的定义,则是隐藏对应的成员方法。这两者具体的区别在于,在持有一个指向子类的父类指针的时候,调用重写的成员方法,C++ 会通过 vtable 找到对应的子类的实现,并调用子类的实现,反之则直接调用父类的实现。另外如果子类想要调用父类对应的方法,而不是子类重写的方法,则可以使用 Parent::xxx 的方式显式调用。

虚函数又具体分为纯虚函数(=0)和普通虚函数,两者都可以被子类重写,但是其核心区别在于前者可以不被父类定义实现,但是后者一定要被父类定义实现。也就是说,虚成员函数应在类内声明,且必须有定义实现,而纯虚函数通常没有定义体,但也可以拥有,尤其是在纯虚析构函数的情况下,它必须有定义体,因为父析构函数的调用会在子类中隐含(这是在 C++ 编译时自动注入的,所以我们不用在析构函数中显式调用父类的析构函数)。

接下来是一些,设计类时的小 trick:

  1. 如果定义了析构函数,则一般肯定需要定义拷贝构造函数与拷贝赋值函数,反之则不一定,通常来说管理类外资源的类必须定义拷贝控制成员

  2. 令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源,但如果我们想自己管理,则需要使用引用计数了:

    1. 引用计数成员变量使用动态内存

    2. 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器并递增共享的计数器

    3. 析构函数递减计数器,如果计数器变为 0,则析构函数释放状态。

    4. 拷贝赋值运算符增加右侧运算对象的计数器,减少左侧对象的计算器。

    赋值操作符重载一定要注意将自己赋值给自己的情况

最后介绍一下,dynamic_caststatic_cast 这两种C++中的两种类型转换操作符,它们都用于在不同类型之间进行类型转换,但是在使用方式和语义上有一些重要的区别:

  • static_cast编译时进行类型检查,它用于进行明确的或者简单的类型转换,主要是横向的转换。
  • dynamic_cast运行时进行类型检查,它用于进行安全的向上转换和向下转换,主要是纵向的转换。dynamic_cast只能用于多态类型(具有虚函数的类)的转换,它将在运行时检查转换的有效性。如果转换有效,返回转换后的指针或引用;如果转换无效,返回空指针(对指针进行转换)或抛出std::bad_cast异常(对引用进行转换)。

操作符重载

C++ 另外一大特点就是它可以操作符重载,比如它可以重载 [], .<< 这写操作符,从而让具体的 DSL 更有表现力,而这也是 Java 做不到的(虽然我觉得大多数操作符重载没啥用,写一个可以表达相同能力的函数名不也可以麻),而这里主要介绍一下一些常用的或者比较令人困惑的操作符重载

  1. 函数调用运算符重载:T operator ()(A a, B b, ...),如果一个类重载了函数调用运算符,那我们可以像使用函数一样使用该类的对象,例如:

    class Add
    {
    public:
    	int operator()(int a, int b) {
            return a + b;
        }    
    };
    Add add_op();
    int res = add_op(1, 2); // res = 3
    

    因为这样的类本身可以存储状态,因此它比普通的函数更加强大,而相较于同样可以存储状态的类成员函数,由于它可以被当作变量使用,因此也更加灵活,再很多的 STL 算法中更倾向于传入一个重载的了函数调用符的对象。另外可以使用 std::function 类模版来统一函数指针,函数对象类以及 lambda 的类型,比如 function<int(int, int)> 表示接收两个 int 并返回一个 int 的可调用对象。除此之外,也可以使用 std::bind(&Class::method, instance, _1) 的方式将一个对象的方法实例化出来,并作为一个变量存在。

  2. 类型转换运算符重载:operator T() const,通过重载类型转换运算符可以控制如何将一个类转化为其他类型,这里给出一个例子:

    #include <iostream>
    using namespace std;
    class Complex
    {
        double real, imag;
    public:
        Complex(double r = 0, double i = 0) :real(r), imag(i) {};
        operator double() { return real; }  //重载强制类型转换运算符 double
    };
    int main()
    {
        Complex c(1.2, 3.4);
        cout << (double)c << endl;  //输出 1.2
        double n = 2 + c;  //等价于 double n = 2 + c. operator double()
        cout << n;  //输出 3.2
    }
    
  3. → 操作符重载:重载 → 操作符需要返回一个指针对象(默认为返回 this ),或者另外一个重载了 → 操作符的对象,在实际运行时,编译器生成的代码会递归调用 → 操作符直到返回一个指针对象 ptr,最终调用 (*ptr).mem 获取或者调用对应的成员。

  4. new 相关操作符重载:new 存在三种操作符,其含义和应用的场景都不同, 这三种操作符分别是 new operator, operator new, placement new

    1. new operator 就是我们常用的 new,它会先调用 operator new 分配空间,再调用类的构造函数, new operator 不可以被重载,但是 operator new 可以被重载。

    2. operator new 被用来分配空间,STL 自带的默认实现为 ::operator new,其显示使用方式如下:

       T* ptr_t = (T*)::operator new(sizeof(T));
      

      每个类型的 operator new 都可以被重载,进而修改 new 的行为,重载方式有两种

      1. 重新实现 void* T::operator new(size_t)方法
      2. 实现 void* T::operator new(size_t) 方法时可以传入额外参数,比如 string,在具体调用时,则使用 new(“param”) T() 的方式进行传参
    3. placement new 则是基于第二种重载 operator new 的方法实现的(但是由标准库实现并且不允许用户修改),它在具体实现中不调用 ::operator new 方法分配空间,而是直接返回传入的指针(第二个参数),从而使得 new 后续的对象构建发生在传入的指针指向的内存上(相当于把分配空间的动作和开销转移到了别的地方)

    在重写对象的 malloc 方式的时候,我们会修改 operator new,但是一般情况下,都用不到。ptmallocglibc 默认的内存管理器。mallocfree 就是由 ptmalloc 内存管理器提供的基础内存分配函数。当我们通过 malloc 或者 free 函数来申请和释放内存的时候,ptmalloc 会将这些内存管理起来,并且通过一些策略来判断是否需要回收给操作系统。这样做的最大好处就是:让用户申请内存和释放内存的时候更加高效(假如每次 malloc 都需要进行系统调用,开销就会很大)。在具体实现中,为了内存分配函数 malloc 的高效性,ptmalloc 会预先向操作系统申请一块内存供用户使用,并且 ptmalloc 会将已经使用的和空闲的内存管理起来;当用户需要销毁内存 free 的时候,ptmalloc 又会将回收的内存管理起来,根据实际情况是否回收给操作系统,这样既省去了归还内存的开销,当用户需要新内存的时候,又可以快速地直接将对应的内存给到用户。

通常情况下,我们将运算符作用于类型正确的实参,从而间接地调用运算符函数,但是我们也可以用函数名称显示地调用,两者本质上是等价的,隐式会在实际运行时转化为显式的调用:

  • 隐式: data1 + data2
  • 显式:data1.operator+(data2) or operator+(data1, data2)

从上述的例子中,我们还可以看到,运算符可以定义为成员函数或者普通的非成员函数,比如下面两者等价,在碰到加号时,会先找 1 号表达式,再去找 2 号表达式:

  • data data::operator+ (constdata &)

  • data operator+ (constdata &,constdata&)

那么具体应该如何做出抉择呢?下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:

  • 赋值(=)、下标([])、调用(())和成员访问箭头(→)运算符必须是成员。
  • 输出输入运算符必须是非成员函数,因为它们需要在 iostream 标准库的基础上调用,且 ostreamistream 是第一个参数
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用,运算符,通常应该是成员。
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

模板

最后,我们来看看 C++ 模板。不得不说,模板算是 C++ 中最复杂的一块地方了,虽然通过模板可以写出很多强大的功能,但是通过模板写出来的代码真的很难看懂,所以模板元编程更多还是在编写库的时候会用到,另外模板元编程也只能从一定程度上弥补 C++ 没有反射的痛点(开始想念 Java 的注解和反射的能力)。这里主要介绍一下模板知识以及一些小 trick,更高级的使用等我学个几年再回来写吧。

另外需要注意的一点是,虚函数不可以和模版函数共同使用,但是可以在一个类中既有虚函数,又有模版函数,虽然子类可以通过定义相同名称的模版函数隐藏父类的模版函数,但是在使用父类指针指向子类的情况下,调用对应的模版函数实际调用的是父类的函数而非子类的函数(因为没有 virtual table),如果是虚函数的话,由于有 virtual table,因此在相同情况下调用的是子类的虚函数。

首先,模版的本质就是在编译时期根据不同的类型生成不同的代码,因此会有模版膨胀的问题,不管是类模板还是函数模板,编译器在看到其定义时只能做最基本的语法检查,真正的类型检查要在实例化(instantiation)的时候才能做。一般而言,这也是编译器会报错的时候。

其次,模版参数包括类型参数非类型参数两种,前者用来代表某种类型,需要在声明时使用 typename 关键字,然后在模板定义中就可以像类名一样使用了,后者则代表某个常量,在声明时需要指出常量类型,然后在模板定义中就可以像常量一样使用了。typename 还可以在模板中显示声明使用作用域访问符访问的某个名字是一个类型而非静态变量。非类型常量可以用来定义 XxxToType 类型,并用作函数的非参类型,从而区分不同的函数实现,例如:

template<int n>
struct Int2Type
{
   enum {value = v};
}

在这种情况下,Int2Type<1> 不等同于 Int2Type<2>,且两者都可以用作函数的**形参类型,**从而定义不同的函数并在编译期分派不同的函数(调用时实例化具体的类型对象),进而可以替代模版中的分支达到避免某些分支虽然不会执行但是编译不通过的情况。

另外,在如下例子: template<typename xxx::xxx = 0>typename 也并不是为了声明一个类型变量,而是代表 xxx::xxx 是一个类型,因此这里是声明了一个匿名非类型变量。

编译器会为实例化模版函数自动推导对应的模版类型,但是在某些编译器无法推导具体类型的情况下(比如:对应的类型参数用在返回值类型定义上),则也需要与类模版一样显示地在模版名后的尖括号中提供额外的类型信息,例如:

template<typename T>
T func() {
	T t();
	return t;
}

T res = func<T>();

在模板元编程的时候,需要注意一点:类模版的名字不是一个类型名,类模版是用来实例化类型的,的而一个实例化的类型总是包含模版参数。

模板参数包

另外,在 C++ 中,只有模版元编程结合参数包才能实现包含变长的不定类型参数,而在编码层面只能使用 initializer_list<T> 类型来让用户传入以花括号包裹的、变长的、固定 T 类型的参数。我们用可以一个省略号来指出一个模板参数或函数参数表示一个参数包(包含变长的不定类型参数):

template <typename T,  typename . . . Args>

void foo (`const`T &t, `const`Args& . . . rest);

在传参时,我们使用 rest . . . , 它会在编译时实际逐项展开 rest ,参数有多少项,展开后就是多少项,我们已可以用std::forward<Arg>(rest)... 做完美转发。

模版元编程的本质往往是递归,在可变参数模版中,对于参数包,我们能做的只有两件事:

  1. 获取大小:std::sizeof…()
  2. 扩展:所谓的扩展就是抽出参数包的第一个元素,显示地作为模版函数的第一个参数(此时是有两个形参的可变函数模版,第一个形参就是参数包的第一个元素,第二个形参就是抽出第一个参数后的参数包),在处理好该参数之后,递归地调用该模版函数,直到参数包中只剩下最后一个参数,从而匹配到终止递归的函数(只有一个形参的非可变函数模版),该递归的本质基于编译器会自动推导并反复生成符合实参的函数这一特性来实现

模板特例化

一个可以真正展现模板用处的地方就是模板特例化,从而在编译时为不同类型的类提供不同方式的实现方法。

由于模板分为函数模板和类模板,所以模板特例化也分为函数模板特例化和类模板特例化:

  1. 函数模版特例化需要为所有的模版参数提供实参,以及 template<>标识符。

  2. 类模版特例化则可以给部分模版参数提供实参。

    1. 在类模版全特例化时,除了把所有的类型参数替换为特定类型标识符外,还需要在类后显示地加上 <>,并在其中填上特定类型
    2. 如果是部分特例化,则没有特例化的类型参数既要在 template 标识符的 <> 中填上,也要在类的 <> 中填上,虽然函数模版不能偏特化,但是可以通过重载达到相同的效果(定义一个类型参数更少的函数模版)。

在模版中可以使用模版特例化来区分不同的类型,比如值和指针,进而提供不同的实现(例如,为指针特例化对应的实现),一个特化的例子:

template<typename T>
class Fuzz<T*> 
{
	T data
}

这代表在面对指针类型时,会把具体的指针指向的类型特例化出来,而不是直接使用指针类型导致产生歧义,从这个例子也可以看出偏特例化甚至可以不需要指定任意一个类型参数的具体类型,只需要对应的偏特例化的类更加细化就可以了,比如:类型参数是指向任何类型的指针就比类型参数是任何类型更加细化

template template 参数

使用 template template 参数可以为类的模版类型定义其所依赖的模版类型,具体如下:

template<template<typename T> typename P>
class Fuzz: public P<Fizz> {
	...
};

这代表对应的模版类型参数 P 本身也是一个模版类,并且在这个例子中 Fuzz 为其实例化了对应的模版类型是 Fizz,在具体使用过程中,Fuzz 甚至还能再实例化其为 Fazz 或者任何其他类型,不过在模板定义中是无法使用 template template 参数的,它只是声明了对应的 P 本身也是一个模板类。

SFINAE (替代失败不是错误原则)

通过 SFINA 结合 enable_if 可以用于需要根据不同类型的条件实例化不同模板的时候,一个例子如下:

template<typename T, typename std::enable_if<std::is_base_of<bar, T>::value, int>::type = 0>
int func(T t) {
	...
}
template<typename T, typename std::enable_if<!std::is_base_of<bar, T>::value, int>::type = 0>
int func(T t) {
	...
}

在这种情况下,会针对 T 类型是否是 bar 类型的子类而实例化不同的模板。

编译时计算与代码生成

在模版元编程中,如果想要记录编译期计算的结果,就需要额外通过类变量去记录,然后外部直接使用类变量的方式去访问它们

  1. 如果是类型结果,则用 typedef 去记录
  2. 如果是值结果,则用 enum 去记录,
  3. 另外还可以使用 template template parameter 方法,定义两个类型变量,一个是普通类型,一个是模版类型,然后继承使用普通类型实例化的模版类型,从而达到生成代码的效果(这个具体展开内容很多,大家有兴趣可以看一下 《C++ 设计新思维》)

万能引用与引用折叠

所谓的万能引用出现在模版 T&& 形参中,在模版推导时,如果传入的实参是左值时,会将 T 推导为引用 T’&,而如果传入的实参是右值时,会将 T 推导为 T’,其中 T’ 是实参类型,最后真正的形参类型 T&& 则根据下述的引用折叠进行推导。

所谓引用折叠就是定义了在引用的引用的情况下会发生什么,引用折叠仅仅出现在模版万能引用 T&& 和 auto&& 推导的情况下,用于模版推导,其规则和与规则有点类似,只有两个引用都是右值引用,最后产生的引用才是右值引用,只要有一个引用是作值引用,那么最后产生的引用就是左值引用。

通过模版万能引用结合引用折叠,我们可以自行实现 std::move 与 std::forward(这两个函数本质上都仅仅是只做了类型强制转换,前者强制转换为右值,后者根据万能引用推导的实参的类型,将形参类型(必定是左值)回退为实参类型并且传递给内部调用的函数):

template<typename T>
decltype(auto) std::move(T&& t) {
    using ReturnType = std::remove_reference_t<T>&&;
   return std::static_cast<ReturnType>(t);
}
template<typename T>
T&& std::forward(std::remove_reference_t<T>& t) {
   return std::static_cast<T&&>(t);
}

具体的 std::forward 使用如下:

template <typename T>
void bar(T&& s)
{
  foo(std::forward<T>(s)); //  由于是万能引用的缘故,会根据 bar 实参传入是左值还是右值,推导出不同的类型 T,并用于 std::forward
}

extern C 关键词

我们可以使用 extern C 关键词去定义和实现一些接口,再结合启动时 PRELOAD 环境变量动态替换具体的实现,从而达到插件化的效果(类似于 JAVA 的 SPI)

extern C 关键词的使用可以从两个角度来解读:

  1. 从库开发者的角度,我们通过引入 extern C 告诉 C++ 编译器按照 C 规则去编译函数代码( C 风格过程式函数),也就是不 name mangling,否则在链接时符号表会找不到对应的符号,因为在 C 中函数修饰名就是函数名,但是 C++ 编译由于存在重载,因此函数修饰名与函数名不对应,也就找不到具体的 C 的实现了。

  2. 从库使用者的角度,我们也通过引入 extern C 来告诉 C++ 用 C 的方式去链接对应的实现库。

在使用 extern C 关键词时,由于对应的函数风格时 C 风格过程式函数,所以对于对象的传递要使用 void * 进行传递,并在实现中使用 static_cast 进行强转,同时对于多个函数之间需要共享的变量,则需要通过全局变量的方式进行访问和修改。