• C++的缺陷和思考(六)


    本文继续来介绍C++的缺陷和笔者的一些思考。先序文章请看
    C++的缺陷和思考(五)
    C++的缺陷和思考(四)
    C++的缺陷和思考(三)
    C++的缺陷和思考(二)
    C++的缺陷和思考(一)

    模板的全特化

    先跑个小题~,模板的「模」正确发音应该是「mú」,原本是工程上的术语,生产一种工件可能需要一种样本,但它和实际生产出的工件可能并不相同。所以说,「模板」本身并不是实际的工件,但可以用于生产出工件。更通俗来说,可以理解成一个浇注用的壳,比如说是圆柱形状,如果你往里灌铁水,那出来的就是铁柱;如果你灌铝水出来的就是铝柱;如果你灌水泥,那出来的就是水泥柱……

    所以C++中用“模板”这个词特别贴切,它本身并不是实际代码,而在实例化的时候才会生成对应的代码。

    而模板又存在“特化”的问题,分为“偏特化”和“全特化”。偏特化也就是部分特化,也就是半成品,本质上来说仍然属于“模板”。但全特化就很特殊了,全特化的模板就已经不是模板了,而是真正的代码了,因此这里的行为也会和模板有所不同,而更加接近普通代码。

    最简单的例子就是,模板的声明和实现一般都会写在头文件中(除非仅在某个源文件中使用)。这是由于模板是编译期代码,在编译期会生成实际代码,而“编译”过程是单文件行为,因此你必须保证每个独立的源文件都能找到这段模板定义。(include头文件本质就是文件内容的复制,所以还是相当于每个使用的源文件都获取了一份模板定义)。而如果拆开则会在编译期间找不到而报错:

    demo.h

    template <typename T>
    void f(T t);
    
    • 1
    • 2

    demo.cpp

    template <typename T>
    void f(T t) {
    // ...
    }
    
    • 1
    • 2
    • 3
    • 4

    main.cpp

    #include "demo.h" // 这里只获得了声明
    
    int main() {
      f<int>(5); // ERR,链接报错,因为只有声明而没有实现
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    上例中,main.cpp包含了demo.h,因此获得的是f函数的声明。当main.cpp在编译期间,是不会去关联demo.cpp的,在主函数中调用了f,因此会标记f函数已经声明。

    而编译demo.cpp的时候,由于f并没有任何实例化,因此不会产生任何代码。

    此后链接main.cpp和demo.cpp,发现main.cpp中的f没有实现,因此链接阶段报错。

    所以,我们才要求模板的实现也要写在头文件中,也就是变成:

    demo.h

    // 声明
    template <typename T>
    void f(T t);
    
    // ...其他内容
    
    // 定义
    template <typename T>
    void f(T t) {
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    main.cpp

    #include "demo.h"
    
    int main() {
      f<int>(5); // OK
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    由于实现也写在了demo.h中,因此当主函数中调用了f时,既会用模板f的声明生成出f的声明,也会用模板f的实现生成出f的实现。

    但是对于全特化的模板,情况将完全不同。因为全特化的模板已经不是模板了,而是一个确定的函数,编译期不会再用它来生成代码,因此,这时如果你把实现也写在头文件里,就会出现重定义错误:

    demo.h

    template <typename T>
    void f(T t) {}
    
    // f全特化
    template <>
    void f<int>(int t) {}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    src1.cpp

    #include "demo.h" // 这里有一份f的实现
    
    • 1

    main.cpp

    #include "demo.h" // 这里也有一份f的实现
    
    int main() {
      f<int>(a); // ERR, redefine f
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这时会报重定义错误,因为f的实现写在了demo.h中,那么src.cpp包含了一次,相当于实现了一次,然后main.cpp也包含了一次,相当于又实现了一次,所以报重定义错误。

    因此,正确的做法是把全特化模板当做普通函数来对待,只能在源文件中定义一次:

    demo.h

    template <typename T>
    void f(T t) {}
    
    // 特化f的声明
    template <>
    void f<int>(int t);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    demo.cpp

    #include "demo.h"
    // 特化f的定义
    template <>
    void f<int>(int t) {}
    
    • 1
    • 2
    • 3
    • 4

    src1.cpp

    #include "demo.h" // 只得到了声明,没有重复实现
    
    • 1

    main.cpp

    #include "demo.h" // 只得到了声明,没有重复实现
    
    int main() {
      f<int>(5); // OK,全局只有一份实现
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    所以在使用模板特化的时候,一定要小心,如果是全特化的话,就要按照普通函数/类来对待,声明和实现需要分开。

    当然了,硬要把实现写在头文件里也是可以的,只不过要用inline修饰,防止重定义。

    demo.h

    template <typename T>
    void f(T t) {}
    
    // 特化f声明
    template <>
    void f<int>(int t);
    
    // 特化f内联定义
    template <>
    inline void f<int>(int t) {}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    构造/析构函数调用虚函数

    我们知道C++用来实现“多态”的语法主要是虚函数。当调用一个对象的虚函数时,会根据对象的实际类型来调用,而不是根据引用/指针的类型。

    class Base {
     public:
      virtual void f() {std::cout << "Base::f" << std::endl;}
    };
    
    class Child1 : public Base {
     public:
      void f() override {std::cout << "Child1::f" << std::endl;}
    };
    
    class Child2 : public Base {
     public:
      void f() override {std::cout << "Child2::f" << std::endl;}
    };
    
    void Demo() {
      Base *obj1 = new Child1;
      Child2 ch;
      Base &obj2 = ch;
      Base obj3;
    
      obj1->f(); // Child1::f
      obj2.f(); // Child2::f
      obj3.f(); // Base::f
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    但有一种特殊情况,会让多态性失效,请看下面例程:

    class Base {
     public:
      Base() {f();} // 构造函数调用虚函数
      virtual void f() {std::cout << "Base::f" << std::endl;}
    };
    
    class Child : public Base {
     public:
      Child() {}
      void f() override {std::cout << "Child::f" << std::endl;}
    };
    
    void Demo() {
      Child ch; // Base::f
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    我们知道子类构造时需要先调用父类构造函数。这里由于Child中没有指定Base的构造函数,因此会调用无参的构造。在Base的无参构造函数中调用了虚函数f。照理说,我们是在构造Child的过程中调用了f,那么应该调用的是Childf,但实际调的是Basef,也就是多态性失效了。

    究其原因,我们就要知道C++构造的模式了。由于ChildBase的子类,因此会含有Base类的成员,并且构造时也要先构造。在构造ChildBase部分时,先初始化了虚函数表,由于此时还属于Base的构造函数,因此虚函数表中指向的是Base::f。虚函数表初始化后开始构造Base的成员,示例中由于是空的所以跳过。再执行Base构造函数的函数体,函数体里调用了f以上都属于Base的构造,完成后才会继续Child独有部分的构造。首先会构造虚函数表,把f指向Child::f。然后是初始化成员,示例中为空所以跳过。最后执行Child构造函数函数体,示例中是空的。

    所以,我们看到,这里调用f的时机,是在Base构造的过程中。f由于是虚函数,因此会通过虚函数表来访问,但又因为此时虚函数表里指向的就是Base::f,所以会调用到Base类的f

    同理,如果在析构函数中调用虚函数的话,同样会失去多态性。原则就是哪个类里调用的,实际就会调用哪个类的实现

    经典二义性问题

    C++中存在3个非常经典的二义性问题,并且他们的默认含义都是反直觉的。

    临时对象传参时的二义性

    请看下面的代码:

    struct Test {};
    
    struct Data {
     explicit Data(const Test &test);
    };
    
    void Demo() {
      Data data(Test()); // 这句是什么含义?
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    上面这种类型的代码确实有时会一不留神就写出来。我们愿意是想创建一个Data类型的对象叫做data,构造参数是一个Test类型,这里我们直接创建了一个临时对象作为构造参数。

    但如果你真的这样写的话,会得到一个warning,并且data这个对象并没有创建成功。为什么呢?因为编译期把它误以为是函数声明了。这里首先需要了解一个语法糖:

    void f(void d(int));
    // 等价于
    void f(void (*d)(int));
    
    • 1
    • 2
    • 3

    C++中允许参数为“函数类型”,又因为函数并不是一种存储类型,因此这种语法会当做“函数指针类型”来处理。所以说当函数参数是一个函数的时候,本质上是让传一个函数指针进去。

    与此同时,C++也支持了“函数取地址”和“解函数指针”的操作。函数取地址后仍然是函数指针,解函数指针后仍然是函数指针:

    void f() {}
    
    void Demo() {
      void (*p1)() = f; // 函数类型转化为函数指针(C语言只支持这种写法)
      void (*p2)() = &f; // 函数类型取地址还是函数指针类型
      p2(); // 函数指针直接调用相当于函数调用
      (*p2)(); // 函数指针解指针后仍然是函数指针
      auto p3 = *p2; // 同上,p3仍然是void (*)()类型
      (*************p2)(); // 逐渐离谱,但确实是合法的
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    再回到一开始的例子,假如我们要声明一个函数名为data,返回值是Data类型,参数是一个函数类型,一个返回值为Test,空参类型的函数。那么就是:

    Data data(Test());
    // 或者是
    Data data(Test (*)());
    
    • 1
    • 2
    • 3

    第一种写法正好和我们刚才想表示“定义Data类型的对象名为data,参数是一个Test类型的临时对象”给撞脸了。引发了二义性。

    解决方法也很简单,我们知道表示“值”的时候,套一层或者多层括号是不影响“值”的意义的:

    // 下面都等价
    a;
    (a);
    ((a));
    
    • 1
    • 2
    • 3
    • 4

    那么表示“函数调用”时,传值也是可以套多层括号的:

    f(a);
    f((a));
    f(((a)));
    
    • 1
    • 2
    • 3

    但是当你表示函数声明的时候,你就不能套多层括号了:

    void f(int); // 函数声明
    void f((int)); // ERR,错误语法
    
    • 1
    • 2

    所以,第一种解决方法就是,套一层括号,那么就只能解释为“函数调用”而不是“函数声明”了:

    Data data((Test())); // 定义对象data,不会出现二义性
    
    • 1

    第二种方法就是不要用小括号表示构造参数,而是换成大括号:

    Data data{Test{}}; // 大括号表示构造参数列表,不能表示函数类型
    
    • 1

    在要不就不要用临时对象,改用普通变量:

    Test t;
    Data data{t};
    
    • 1
    • 2

    模板参数嵌套时的二义性

    当两个模板参数套在一起的时候,两个>会碰在一起:

    std::vector<std::vector<int>> ve; // 这里出现了一个>>
    
    • 1

    而这和参数中的右移运算给撞脸了:

    std::array<int, 1 >> 5> arr; // 这里也出现了一个>>
    
    • 1

    在C++11以前,>>会优先识别为右移符号,因此对于模板嵌套,就必须加空格:

    std::vector<std::vector<int> > ve; // 加空格避免歧义
    
    • 1

    但可能是因为模板参数右移的情况远远少过模板嵌套的情况,因此在C++11开始,把这种默认情况改了过来,遇见>>会识别为模板嵌套:

    std::vector<std::vector<int>> ve; // OK
    
    • 1

    但相对的,如果要进行右移运算的话,就会识别错误,解决方法是加括号

    std::array<int, 1 >> 5> arr; // ERR
    std::array<int, (1 >> 5)> arr; // OK,要通过加小括号避免歧义
    
    • 1
    • 2

    模板中类型定义和静态变量二义性

    直接上代码:

    template <typename T>
    struct Test {
      void f() {
      	T::abc *p;
      }
    };
    
    struct T1 {
      static int abc;
    };
    
    struct T2 {
      using abc = int;
    };
    
    void Demo() {
      Test<T1> t1;
      Test<T2> t2;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    Test是一个模板类,里面取了参数T的成员abc。对于T1的实例化来说,T1::abc是一个整型变量,所以T::abc *p相当于两个变量相乘,*会理解为“乘法”。

    而对于T2来说,T2::abc是一个类型重命名,那么T::abc *p相当于定义一个int类型的指针,*会理解为指针类型。

    所以,对于模板Test来说,由于T还没有实例化,所以不能确定T::abc到底是静态变量还是类型重命名。因此会出现二义性。

    解决方式是用typename关键字,强制表名这里T::abc是一个类型:

    template <typename T>
    struct Test {
      void f() {
        typename T::abc *p; // 一定表示指针定义
      }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    typename关键字大家应该并不陌生,但一般都是在模板参数中见到的。其实在C++11以前,模板参数中表示“类型”参数的关键字是class,但用这个关键字会对人产生误导,其实这里不一定非要传类类型,传基本类型也是OK的,因此C++11的时候让typename可以承担这个责任,因为它更能表示“类型名称”这种含义。但其实在此之前typename仅仅是为了解决上面二义性问题的。

    另外值得说明的一点是,C++17以前,模板参数是模板的情况时仍然只能用class

    // 要求参数要传一个模板类型,其含有两个类型参数
    // C++14及以前版本这里必须用class
    template <template <typename, typename> class Temp>
    struct Test {}
    
    template <typename T, typename R>
    struct T1 {}
    
    void Demo() {
      Test<T1>; // 模板参数是模板的情况实例化
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    C++17开始才允许这个class替换为typename

    // C++17后可以用typename
    template <template <typename, typename> typename Temp>
    struct Test {}
    
    • 1
    • 2
    • 3

    第七篇(也是完结篇)已脱稿,请看C++的缺陷和思考(七)

  • 相关阅读:
    【Java八股40天-Day3】 集合类1
    【ElasticSearch应用】
    Ubuntu下VMware出现:Unable to install all modeules.的解决方法
    MQ写满的情况如何处理?
    【毕业设计】基于Android的餐饮管理系统APP毕业设计源码
    算法与数据结构 --- 线性表 --- 链式表示与实现(下)
    Vue样式绑定
    胡珈魁:00后工程师的AI进阶之路 | OneFlow U
    JVM下篇(三、JVM监控及诊断工具-GUI篇)
    计算机网络---UDP协议
  • 原文地址:https://blog.csdn.net/fl2011sx/article/details/126490825