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


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

    new和delete

    new这个运算符相信大家一定不陌生,即便是非C++系其他语言一般都会保留new这个关键字。而且这个已经成为业界的一个哏了,比如说“没有对象怎么办?不怕,new一个!”
    从字面意思就能看得出,这是“新建”的意思,不过在C++中,new远不止字面看上去这么简单。而且,delete关键字基本算得上是C++的特色了,其他语言中基本见不到。

    分配和释放空间

    “堆空间”的概念同样继承自C语言,它是提供给程序手动管理、调用的内存空间。在C语言中,malloc用于分配堆空间,free用于回收。自然,在C++中仍然可以用mallocfree
    但使用malloc有一个不方便的地方,我们来看一下malloc的函数原型:

    void *malloc(size_t size);
    
    • 1

    malloc接收的是字节数,也就是我们需要手动计算出我们需要的空间是多少字节。它不能方便地通过某种类型直接算出空间,通常需要sizeof运算。
    malloc返回值是void *类型,是一个泛型指针,也就是没有指定默认解类型的,使用时通常需要类型转换,例如:

    int *data = (int *)malloc(sizeof(int));
    
    • 1

    new运算符可以完美解决上面的问题,注意,在C++中new是一个运算符

    int *data = new int;
    
    • 1

    同理,delete也是一个运算符,用于释放空间:

    delete data;
    
    • 1

    运算符本质是函数调用

    熟悉C++运算符重载的读者一定清楚,C++中运算符的本质其实就是一个函数的语法糖,例如a + b实际上就是operator +(a, b)a++实际上就是a.operator++(),甚至仿函数、下标运算也都是函数调用,比如f()就是f.operator()()a[i]就是a.operator[](i)
    既然newdelete也是运算符,那么它就应当也符合这个原理,一定有一个operator new的函数存在,下面是它的函数原型:

    void *operator new(size_t size);
    void *operator new(size_t size, void *ptr);
    
    • 1
    • 2

    这个跟我们直观想象可能有点不一样,它的返回值仍然是void *,也并不是一个模板函数用来判断大小。所以,new运算符跟其他运算符并不一样,它并不只是单纯映射成operator new,而是做了一些额外操作。
    另外,这个拥有2个参数的重载又是怎么回事呢?这个等一会再来解释。
    系统内置的operator new本质上就是malloc,所以如果我们直接调operator newoperator delete的话,本质上来说,和mallocfree其实没什么区别:

    int *data = static_cast<int *>(operator new(sizeof(int)));
    operator delete(data);
    
    • 1
    • 2

    而当我们用运算符的形式来书写时,编译器会自动处理类型的大小,以及返回值。new运算符必须作用于一个类型,编译器会将这个类型的size作为参数传给operator new,并把返回值转换为这个类型的指针,也就是说:

    new T;
    // 等价于
    static_cast<T *>(operator new(sizeof(T)))
    
    • 1
    • 2
    • 3

    delete运算符要作用于一个指针,编译器会将这个指针作为参数传给operator delete,也就是说:

    delete ptr;
    // 等价于
    operator delete(ptr);
    
    • 1
    • 2
    • 3

    重载new和delete

    之所以要引入operator newoperator delete还有一个原因,就是可以重载。默认情况下,它们操作的是堆空间,但是我们也可以通过重载来使得其操作自己的内存池

    std::byte buffer[16][64]; // 一个手动的内存池
    std::array<void *, 16> buf_mark {nullptr}; // 统计已经使用的内存池单元
    
    struct Test {
      int a, b;
      static void *operator new(size_t size) noexcept; // 重载operator new
      static void operator delete(void *ptr); // 重载operator delete
    };
    
    void *Test::operator new(size_t size) noexcept {
      // 从buffer中分配资源
      for (int i = 0; i < 16; i++) {
        if (buf_mark.at(i) == nullptr) {
          buf_mark.at(i) = buffer[i];
          return buffer[i];
        }
      }
      return nullptr;
    }
    
    void Test::operator delete(void *ptr) {
      for (int i = 0; i < 16; i++) {
        if (buf_mark.at(i) == ptr) {
          buf_mark.at(i) = nullptr;
        }
      }
    }
    
    void Demo() {
      Test *t1 = new Test; // 会在buffer中分配
      delete t1; // 释放buffer中的资源
    }
    
    • 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
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    另一个点,相信大家已经发现了,operator newoperator delete是支持异常抛出的,而我们这里引用直接用空指针来表示分配失败的情况了,于是加上了noexcept修饰。而默认的情况下,可以通过接收异常来判断是否分配成功,而不用每次都对指针进行判空。

    构造函数和placement new

    malloc的另一个问题就是处理非平凡构造的类类型。当一个类是非平凡构造时,它可能含有虚函数表、虚基表,还有可能含有一些额外的构造动作(比如说分配空间等等),我们拿一个最简单的字符串处理类为例:

    class String {
     public:
      String(const char *str);
      ~String();
     private:
      char *buf;
      size_t size;
      size_t capicity;
    };
    
    String::String(const char *str):
        buf((char *)std::malloc(std::strlen(str) + 1)), 
        size(std::strlen(str)), 
        capicity(std::strlen(str) + 1) {
      std::memcpy(buf, str, capicity);
    }
    String::~String() {
      if (buf != nullptr) {
        std::free(buf);
      }
    }
    
    void Demo() {
      String *str = (String *)std::malloc(sizeof(String)); 
      // 再使用str一定是有问题的,因为没有正常构造
    }
    
    • 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
    • 26

    上面例子中,String就是一个非平凡的类型,它在构造函数中创建了堆空间。如果我们直接通过malloc分配一片String大小的空间,然后就直接用的话,显然是会出问题的,因为构造函数没有执行,其中buf管理的堆空间也是没有进行分配的。
    所以,在C++中,创建一个对象应该分2步:

    1. 分配内存空间
    2. 调用构造函数

    同样,释放一个对象也应该分2步:
    3. 调用析构函数
    4. 释放内存空间

    这个理念在OC语言中贯彻得非常彻底,OC中没有默认的构造函数,都是通过实现一个类方法来进行构造的,因此构造前要先分配空间:

    NSString *str = [NSString alloc]; // 分配NSString大小的内存空间
    [str init]; // 调用初始化函数
    // 通常简写为:
    NSString *str = [[NSString alloc] init];
    
    • 1
    • 2
    • 3
    • 4

    但是在C++中,初始化方法并不是一个普通的类方法,而是特殊的构造函数,那如何手动调用构造函数呢?
    我们知道,要想调用构造函数(构造一个对象),我们首先需要一个分配好的内存空间。因此,要拿着用于构造的内存空间,以构造参数,才能构造一个对象(也就是调用构造函数)。C++管这种语法叫做就地构造(placement new)

    String *str = static_cast<String *>(std::malloc(sizeof(String))); // 分配内存空间
    new(str) String("abc"); // 在str指向的位置调用String的构造函数
    
    • 1
    • 2

    就地构造的语法就是new(addr) T(args...),看得出,这也是new运算符的一种。这时我们再回去看operator new的一个重载,应该就能猜到它是干什么的了:

    void *operator new(size_t size, void *ptr);
    
    • 1

    就是用于支持就地构造的函数。
    要注意的是,如果是通过就地构造方式构造的对象,需要再回收内存空间之前进行析构。以上面String为例,如果不析构直接回收,那么buf所指的空间就不能得到释放,从而造成内存泄漏:

    str->~String(); // 析构
    std::free(str); // 释放内存空间
    
    • 1
    • 2

    new = operator new + placement new

    看到本节的标题,相信读者会恍然大悟。C++中new运算符同时承担了“分配空间”和“构造对象”的任务。上一节的例子中我们是通过mallocfree来管理的,自然,通过operator newoperator delete也是一样的,而且它们还支持针对类型的重载。
    因此,我们说,一次new,相当于先operator new(分配空间)加placement new(调用构造函数)。

    String *str = new String("abc"); 
    // 等价于
    String *str = static_cast<String *>(operator new(sizeof(String)));
    new(str) String("abc");
    
    • 1
    • 2
    • 3
    • 4

    同理,一次delete相当于先“析构”,再operator delete(释放空间)

    delete str;
    // 等价于
    str->~String();
    operator delete(str);
    
    • 1
    • 2
    • 3
    • 4

    这就是newdelete的神秘面纱,它确实和普通的运算符不一样,除了对应的operator函数外,还有对构造、析构的处理。
    但也正是由于C++总是进行一些隐藏操作,才会复杂度激增,有时也会出现一些难以发现的问题,所以我们一定要弄清楚它的本质。

    new []和delete []

    new []delete []的语法看起来是“创建/删除数组”的语法。但其实它们也并不特殊,就是封装了一层的newdelete

    void *operator new[](size_t size);
    void operator delete[](void *ptr);
    
    • 1
    • 2

    可以看出,operator new[]operator new完全一样,opeator delete[]operator delete也完全一样,所以区别应当在编译器的解释上。
    operator new T[size]的时候,会计算出sizeT类型的总大小,然后调用operator new[],之后,会依次对每个元素进行构造。也就是说:

    String *arr_str = new String [4] {"abc", "def", "123"};
    // 等价于
    String *arr_str = static_cast<String *>(opeartor new[](sizeof(String) * 3));
    new(arr_str) String("abc");
    new(arr_str + 1) String("def");
    new(arr_str + 2) String("123");
    new(arr_str + 3) String; // 没有写在列表中的会用无参构造函数
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    同理,delete []会首先依次调用析构,然后再调用operator delete []来释放空间:

    delete [] arr_str;
    // 等价于
    for (int i = 0; i < 4; i++) {
      arr_str[i].~String();
    }
    operator delete[] (arr_str);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    总结下来new []相当于一次内存分配加多次就地构造,delete []运算符相当于多次析构加一次内存释放。

    constexpr

    constexpr全程叫“常量表达式(constant expression)”,顾名思义,将一个表达式定义为“常量”。
    关于“常量”的概念笔者在前面“const引用”的章节已经详细叙述过,只有像1'a'2.5f之类的才是真正的常量。储存在内存中的数据都应当叫做“变量”。
    但很多时候我们在程序编写的时候,会遇到一些编译期就能确定的量,但不方便直接用常量表达的情况。最简单的一个例子就是“魔鬼数字”:

    using err_t = int;
    err_t Process() {
      // 某些错误
      return 25;
      // ...
      return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    作为错误码的时候,我们只能知道业界约定0表示成功,但其他的错误码就不知道什么含义了,比如这里的25号错误码,非常突兀,根本不知道它是什么含义。
    C中的解决的办法就是定义宏,又有宏是预编译期进行替换的,因此它在编译的时候一定是作为常量存在的,我们又可以通过宏名称来增加可读性:

    #define ERR_DATA_NOT_FOUNT 25
    #define SUCC 0
    
    using err_t = int;
    err_t Process() {
      // 某些错误
      return ERR_DATA_NOT_FOUNT;
      // ...
      return SUCC;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    (对于错误码的场景当然还可以用枚举来实现,这里就不再赘述了。)
    用宏虽然可以解决魔数问题,但是宏本身是不推荐使用的,详情大家可以参考前面“宏”的章节,里面介绍了很多宏滥用的情况。
    不过最主要的一点就是宏不是类型安全的。我们既希望定义一个类型安全的数据,又不希望这个数据成为“变量”来占用内存空间。这时,就可以使用C++11引入的constexpr概念。

    constexpr double pi = 3.141592654;
    double Squ(double r) {
      return pi * r * r;
    }
    
    • 1
    • 2
    • 3
    • 4

    这里的pi虽然是double类型的,类型安全,但因为用constexpr修饰了,因此它会在编译期间成为“常量”,而不会占用内存空间。
    constexpr修饰的表达式,会保留其原有的作用域和类型(例如上面的pi就跟全局变量的作用域是一样的),只是会变成编译期常量。

    constexpr可以当做常量使用

    既然constexpr叫“常量表达式”,那么也就是说有一些编译期参数只能用常量,用constexpr修饰的表达式也可以充当。
    举例来说,模板参数必须是一个编译期确定的量,那么除了常量外,constexpr修饰的表达式也可以:

    template <int N>
    struct Array {
      int data[N];
    };
    
    constexpr int default_size = 16;
    const int g_size = 8;
    void Demo() {
      Array<8> a1; // 常量OK
      Array<default_size> a2; // 常量表达式OK
      Array<g_size> a3; // ERR,非常量不可以,只读变量不是常量
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    至于其他类型的表达式,也支持constexpr,原则在于它必须要是编译期可以确定的类型,比如说POD类型:

    constexpr int arr[] {1, 2, 3}; 
    constexpr std::array<int> arr2 {1, 2, 3};
    
    void f() {}
    
    constexpr void (*fp)() = f;
    constexpr const char *str = "abc123";
    
    int g_val = 5;
    constexpr int *pg = &g_val;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这里可能有一些和直觉不太一样的地方,我来解释一下。首先,数组类型是编译期可确定的(你可以单纯理解为一组数,使用时按对应位置替换为值,并不会真的分配空间)。
    std::array是POD类型,那么就跟普通的结构体、数组一样,所以都可以作为编译期常量。
    后面几个指针需要重点解释一下。用constexpr修饰的除了可以是绝对的常量外,在编译期能确定的量也可以视为常量。比如这里的fp,由于函数f的地址,在运行期间是不会改变的,编译期间尽管不能确定其绝对地址,但可以确定它的相对地址,那么作为函数指针fp,它就是f将要保存的地址,所以,这就是编译期可以确定的量,也可用constexpr修饰。
    同理,str指向的是一个字符串常量,字符串常量同样是有一个固定存放地址的,位置不会改变,所以用于指向这个数据的指针str也可以用constexpr修饰。要注意的是:constexpr表达式有固定的书写位置,**与const的位置不一定相同。比如说这里如果定义只读变量应该是const char *const str,后面的const修饰str,前面的const修饰char。但换成常量表达式时,constexpr要放在最前,因此不能写成const char *constexpr str,而是要写成constexpr const char *str。当然,少了这个const也是不对的,因为不仅是指针不可变,指针所指数据也不可变。这个也是C++中推荐的定义字符串常量别名的方式,优于宏定义。
    最后的这个pg也是一样的道理,因为全局变量的地址也是固定的,运行期间不会改变,因此pg也可以用常量表达式。
    当然,如果运行期间可能发生改变的量(也就是编译期间不能确定的量)就不可以用常量表达式,例如:

    void Demo() {
      int a;
      constexpr int *p = &a; // ERR,局部变量地址编译期间不能确定
      static int b;
      constexpr int *p2 = &b; // OK,静态变量地址可以确定
    
      constexpr std::string str = "abc"; // ERR,非平凡POD类型不能编译期确定内部行为
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    constexpr表达式也可能变成变量

    希望读者看到这一节标题的时候不要崩溃,C++就是这么难以捉摸。
    没错,虽然constexpr已经是常量表达式了,但是用constexpr修饰变量的时候,它仍然是“定义变量”的语法,因此C++希望它能够兼容只读变量的情况。
    当且仅当一种情况下,constexpr定义的变量会真的成为变量,那就是这个变量被取址的时候:

    void Demo() {
      constexpr int a = 5;
      int *p = &a; // 会让a退化为const int类型
    }
    
    • 1
    • 2
    • 3
    • 4

    道理也很简单,因为只有变量才能取址。上面例子中,由于对a进行了取地址操作,因此,a不得不真正成为一个变量,也就是变为const int类型。
    那另一个问题就出现了,如果说,我对一个常量表达式既取了地址,又用到编译期语法中了怎么办?

    template <int N>
    struct Test {};
    
    void Demo() {
      constexpr int a = 5;
      Test<a> t; // 用做常量
      int *p = &a; // 用做变量
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    没关系,编译器会让它在编译期视为常量去给那些编译期语法(比如模板实例化)使用,之后,再把它用作变量写到内存中。
    换句话说,在编译期,这里的a相当于一个宏,所有的编译期语法会用5替换aTest就变成了Test<5>。之后,又会让a成为一个只读变量写到内存中,也就变成了const int a = 5;那么int *p = &a;自然就是合法的了。

    就地构造

    “就地构造”这个词本身就很C++。很多程序员都能发现,到处纠结对象有没有拷贝,纠结出参还是返回值的只有C++程序员。
    无奈,C++确实没法完全摆脱底层考虑,C++程序员也会更倾向于高性能代码的编写。当出现嵌套结构的时候,就会考虑复制问题了。
    举个最简单的例子,给一个vector进行push_back操作时,会发生一次复制:

    struct Test {
      int a, b;
    };
    
    void Demo() {
      std::vector<Test> ve;
      ve.push_back(Test{1, 2}); // 用1,2构造临时对象,再移动构造
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    原因就在于,push_back的原型是:

    template <typename T>
    void vector<T>::push_back(const T &);
    template <typename T>
    void vector<T>::push_back(T &&);
    
    • 1
    • 2
    • 3
    • 4

    如果传入左值,则会进行拷贝构造,传入右值会移动构造。但是对于Test来说,无论深浅复制,都是相同的复制。这多构造一次Test临时对象本身就是多余的。
    既然,我们已经有{1, 2}的构造参数了,能否想办法跳过这一次临时对象,而是直接在vector末尾的空间上进行构造呢?这就涉及了就地构造的问题。我们在前面“new和delete”的章节介绍过,“分配空间”和“构造对象”的步骤可以拆解开来做。首先对vector的buffer进行扩容(如果需要的话),确定了要放置新对象的空间以后,直接使用placement new进行就地构造。
    比如针对Testvector我们可以这样写:

    template <>
    void vector<Test>::emplace_back(int a, int b) {
      // 需要时扩容
      // new_ptr表示末尾为新对象分配的空间
      new(new_ptr) Test{a, b};
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    STL中把容器的就地构造方法叫做emplace,原理就是通过传递构造参数,直接在对应位置就地构造。所以更加通用的方法应该是:

    template <typename T, typename... Args>
    void vector<T>::emplace_back(Args &&...args) {
      // new_ptr表示末尾为新对象分配的空间
      new(new_ptr) T{std::forward<Args>(args)...};
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    嵌套就地构造

    就地构造确实能在一定程度上解决多余的对象复制问题,但如果是嵌套形式就实则没办法了,举例来说:

    struct Test {
      int a, b;
    };
    
    void Demo() {
      std::vector<std::tuple<int, Test>> ve;
      ve.emplace_back(1, Test{1, 2}); // tuple嵌套的Test没法就地构造
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    也就是说,我们没法在就地构造对象时对参数再就地构造。
    这件事情放在map或者unordered_map上更加有趣,因为这两个容器的成员都是std::pair,所以对它进行emplace的时候,就地构造的是pair而不是内部的对象:

    struct Test {
      int a, b;
    };
    
    void Demo() {
      std::map<int, Test> ma;
      ma.emplace(1, Test{1, 2}); // 这里emplace的对象是pair
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    不过好在,mapunordered_map提供了try_emplace方法,可以在一定程度上解决这个问题,函数原型是:

    template <typename K, typename V, typename... Args>
    std::pair<iterator, bool> map<K, V>::try_emplace(const K &key, Args &&...args);
    
    • 1
    • 2

    这里把keyvalue拆开了,前者还是只能通过复制的方式传递,但后者可以就地构造。(实际使用时,value更需要就地构造,一般来说key都是整数、字符串这些。)那么我们可用它代替emplace:

    void Demo() {
      std::map<int, Test> ma;
      ma.try_emplace(1, 1, 2); // 1, 2用于构造Test
    }
    
    • 1
    • 2
    • 3
    • 4

    但看这个函数名也能猜到,它是“不覆盖逻辑”。也就是如果容器中已有对应的key,则不会覆盖。返回值中第一项表示对应项迭代器(如果是新增,就返回新增这一条的迭代器,如果是已有key则放弃新增,并返回原项的迭代器),第二项表示是否成功新增(如果已有key会返回false)。

    void Demo() {
      std::map<int, Test> ma {{1, Test{1, 2}}};
      auto [iter, is_insert] = ma.try_emplace(1, 7, 8);
      auto &current_test = iter->second;
      std::cout << current_test.a << ", " << current_test.b << std::endl; // 会打印1, 2
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    不过有一些场景利用try_emplace会很方便,比如处理多重key时使用map嵌套map的场景,如果用emplace要写成:

    void Demo() {
      std::map<int, std::map<int, std::string>> ma;
      // 例如想给key为(1, 2)新增value为"abc"的
      // 由于无法确定外层key为1是否已经有了,所以要单独判断
      if (ma.count(1) == 0) {
        ma.emplace(1, std::map<int, std::string>{});
      }
      ma.at(1).emplace(1, "abc");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    但是利用try_emplace就可以更取巧一些:

    void Demo() {
      std::map<int, std::map<int, std::string>> ma;
      ma.try_emplace(1).first->second.try_emplace(1, "abc");
    }
    
    • 1
    • 2
    • 3
    • 4

    解释一下,如果ma含有key1的项,就返回对应迭代器,如果没有的话则会新增(由于没指定后面的参数,所以会构造一个空map),并返回迭代器。迭代器在返回值的第一项,所以取first得到迭代器,迭代器指向的是map内部的pair,取second得到内部的map,再对其进行一次try_emplace插入内部的元素。
    当然了,这么做确实可读性会下降很多,具体使用时还需要自行取舍。

    第六篇已脱稿,请看C++的缺陷和思考(六)

  • 相关阅读:
    有关神经网络的训练算法,神经网络训练算法公式
    5分钟快速上手Nmap指令(基于Kali系统)
    使用qrcode.js生成二维码
    信息学奥赛一本通:1150:求正整数2和n之间的完全数
    MySQL 8.0 新特性之不可见主键
    专用神经网络处理器的芯片,cpu可以跑神经网络吗
    springboot+skywalking初体检
    Yolov5
    SpringMVC-19-springmvc文件上传
    基于PHP+MySQL校园网站的设计与实现
  • 原文地址:https://blog.csdn.net/fl2011sx/article/details/126407455