C++ 类成员初始化发展历程(从C++11到C++20)

介绍

在现代 C++(自 C++11 起)中, 有许多新特性可以简化我们的工作, 使代码更直观. 本文介绍有关类成员初始化方面的知识, 包括:

构造函数

构造函数是一种特殊的成员函数, 用于初始化类的对象. 构造函数的名称与类名称相同, 没有返回类型, 也没有返回值. 构造函数可以有参数, 也可以没有参数.

#include <iostream>

struct Position {
  int x;
  int y;

  Position() : x(0), y(0) { std::cout << "Default constructor called\n"; }
  Position(int _x, int _y) : x(_x), y(_y) {
    std::cout << "Constructor with arguments called, {x: " << x << ", y: " << y
              << "}\n";
  }
};

int main() {
  Position a;        // calls the default constructor
  Position b(1, 2);  // calls the constructor with two arguments
  Position c{3, 4};  // another call using brace initialization
}

可以看到Position类有两个构造函数, 一个是默认构造函数, 另一个是带有两个参数的构造函数.

需要注意的是编译器是按照类成员声明的顺序初始化的, 而不是按照初始化列表的顺序. 如果出现两者的顺序不一致, 则会出现警告.

拷贝构造函数

拷贝构造函数是一种特殊的成员函数, 用于创建一个新对象, 该对象是另一个对象的副本.

#include <iostream>

struct Position {
  int x;
  int y;

  Position() : x(0), y(0) { std::cout << "Default constructor called\n"; }
  Position(int _x, int _y) : x(_x), y(_y) {
    std::cout << "Constructor with arguments called, {x: " << x << ", y: " << y
              << "}\n";
  }
  Position(const Position& rhs) : x(rhs.x), y(rhs.y) {
    std::cout << "Copy constructor called, {x: " << x << ", y: " << y << "}\n";
  }
};

int main() {
  Position a;        // calls the default constructor
  Position b(1, 2);  // calls the constructor with two arguments
  Position c{3, 4};  // another call using brace initialization
  Position d(b);     // calls the copy constructor
  Position e{c};     // calls the copy constructor
  Position f = a;    // calls the copy constructor
}

移动构造函数(From C++11)

移动构造函数有一个右值引用作为参数, 用于将临时对象的资源转移到新对象.

#include <iostream>

struct Position {
  int x;
  int y;

  Position() : x(0), y(0) { std::cout << "Default constructor called\n"; }
  Position(int _x, int _y) : x(_x), y(_y) {
    std::cout << "Constructor with arguments called, {x: " << x << ", y: " << y
              << "}\n";
  }
  Position(const Position& rhs) : x(rhs.x), y(rhs.y) {
    std::cout << "Copy constructor called, {x: " << x << ", y: " << y << "}\n";
  }
  Position(Position&& rhs) noexcept : x(rhs.x), y(rhs.y) {
    std::cout << "Move constructor called, {x: " << x << ", y: " << y << "}\n";
  }
  // overload + operator
  friend Position&& operator+(const Position& lhs, const Position& rhs) {
    return std::move(Position(lhs.x + rhs.x, lhs.y + rhs.y));
  }
};

int main() {
  Position a;                // calls the default constructor
  Position b(1, 2);          // calls the constructor with two arguments
  Position c{3, 4};          // another call using brace initialization
  Position d(b);             // calls the copy constructor
  Position e{c};             // calls the copy constructor
  Position f = a;            // calls the copy constructor
  Position g(std::move(a));  // calls the move constructor
  Position h(b + c);         // calls the move constructor for temporary object
}

与赋值操作符的区别

#include <iostream>

struct Position {
  int x;
  int y;

  Position() : x(0), y(0) { std::cout << "Default constructor called\n"; }
  Position(int _x, int _y) : x(_x), y(_y) {
    std::cout << "Constructor with arguments called, {x: " << x << ", y: " << y
              << "}\n";
  }
  Position(const Position& rhs) : x(rhs.x), y(rhs.y) {
    std::cout << "Copy constructor called, {x: " << x << ", y: " << y << "}\n";
  }
  Position(Position&& rhs) noexcept : x(rhs.x), y(rhs.y) {
    std::cout << "Move constructor called, {x: " << x << ", y: " << y << "}\n";
  }
  // overload + operator
  friend Position&& operator+(const Position& lhs, const Position& rhs) {
    return std::move(Position(lhs.x + rhs.x, lhs.y + rhs.y));
  }

  // copy assignment operator
  Position& operator=(const Position& rhs) {
    x = rhs.x;
    y = rhs.y;
    std::cout << "Copy assignment operator called, {x: " << x << ", y: " << y
              << "}\n";
    return *this;
  }

  Position& operator=(Position&& rhs) noexcept {
    x = rhs.x;
    y = rhs.y;
    std::cout << "Move assignment operator called, {x: " << x << ", y: " << y
              << "}\n";
    return *this;
  }
};

int main() {
  Position a;
  Position b{a};   // copy constructor called!
  Position c = b;  // copy ctor called!
  c = a;           // assignment operator called!

  std::cout << "====================\n";
  Position e{1, 2}, f;
  Position g{e + f};  // move constructor called for the temporary!
  g = e + f;          // move assignment called
}

委托构造函数(From C++11)

委托构造函数是一个构造函数, 它调用同一个类中的另一个构造函数. 下面的例子中, 我们定义了一个委托构造函数, 它调用了带有两个参数的构造函数.

struct Position {
  int x;
  int y;

  Position() : Position(0, 0) {}
  Position(int _x, int _y) : x(_x), y(_y) {}
};

int main() {
  Position a;
  Position b(1, 2);
}

委托构造函数的限制

过多的委托构造函数可能会导致一些错误和递归调用. 参考下面例子:

#include <iostream>

struct Delegate {
  int width;
  int height;

  Delegate() : Delegate(0, 0) {}
  Delegate(int w, int h) : width(w), height(h) {
    if (width < 0 || height < 0) {
      throw std::invalid_argument("w and h must be non-negative");
    }
  }

};

int main() {}

另外一个限制是不能同时使用构造函数列表和委托构造函数.

非静态数据成员初始化

数据成员默认值(From C++11)

从 C++11 开始, 我们可以在类定义中为非静态数据成员提供默认值.

#include <cassert>

struct Position {
  int x = 0;
  int y = 0;
};

int main() {
  Position a;  // calls the default constructor
  assert(a.x == 0);
  assert(a.y == 0);
}

如何工作

我们想知道默认初始化如何工作. 下面的例子中, 我们定义了一个结构体, 其中有两个非静态数据成员, 一个有默认值, 另一个没有. 我们在构造函数中显式初始化一个成员, 另一个使用默认初始化. 我们在 main 函数中调用了构造函数, 并且在构造函数中调用了函数 getX()getY().

#include <cassert>
#include <iostream>

int getX() {
  std::cout << "getX called\n";
  return 1;
}

int getY() {
  std::cout << "getY called\n";
  return 2;
}

struct Position {
  int x = getX();
  int y = getY();

  Position() = default;

  // 此处没有初始化y, y会使用默认初始化
  Position(int _x) : x(_x) {}
};

int main() {
  Position a;  // calls the default constructor

  std::cout << "====================\n";
  Position b(10);  // calls the constructor with one argument
}

上面函数的输出结果是:

getX called
getY called
====================
getY called

所以我们看到如果在构造函数的初始化列表中指定了成员变量的值, 那么会使用初始化列表中的值, 而不是使用默认初始化. 否则会使用默认初始化.

拷贝构造函数

#include <cassert>
#include <iostream>

int getX() {
  std::cout << "getX called\n";
  return 1;
}

int getY() {
  std::cout << "getY called\n";
  return 2;
}

struct Position {
  int x = getX();
  int y = getY();

  Position() = default;
  // 此处没有初始化y, y会使用默认初始化
  Position(int _x) : x(_x) {}

  Position(const Position& rhs) {
    std::cout << "copy ctor\n";
    x = rhs.x;
    y = rhs.y;
  };
};

int main() {
  Position a;  // calls the default constructor
  std::cout << "====================\n";
  Position b = a;  // calls the copy constructor
}

上面函数的输出结果是:

getX called
getY called
====================
getX called
getY called
copy ctor

可以看到成员变量此时先被赋值,接着被覆盖.

改进方法:

Position(const Position& rhs) = default;
// or
Position(const Position& rhs) : x(rhs.x), y(rhs.y) { }

移动构造函数

#include <cassert>
#include <iostream>

int getX() {
  std::cout << "getX called\n";
  return 1;
}

int getY() {
  std::cout << "getY called\n";
  return 2;
}

struct Position {
  int x = getX();
  int y = getY();

  Position() = default;
  // 此处没有初始化y, y会使用默认初始化
  Position(int _x) : x(_x) {}

  Position(const Position& rhs) {
    std::cout << "copy ctor called\n";
    x = rhs.x;
    y = rhs.y;
  };
  Position(const Position&& rhs) {
    std::cout << "move ctor called\n";
    x = rhs.x;
    y = rhs.y;
  };
};

int main() {
  Position a;  // calls the default constructor
  std::cout << "====================\n";
  Position b = std::move(a);  // calls the copy constructor
}

上面函数的输出结果是:

getX called
getY called
====================
getX called
getY called
move ctor called

我们还是看到成员变量此时先被赋值,接着被覆盖. 可以采用类似的方法避免

Position(Position&&) = default;
// or
Position(Position&& rhs) : x(std::move(rhs.x)), y(std::move(rhs.y)) { }

C++14 的更新

起初在 C++11 中, 如果使用默认成员初始化, 你的类不能是一个聚合类型:

struct Point {
    int x = 1.0;
    int y = 2.0;
};

// won't compile in C++11
Point a{1, 2};

在 C++14 中, 这个限制被取消了, 你可以在聚合类型中使用默认成员初始化:

#include <iostream>
struct Point {
    int x = 1.0;
    int y = 2.0;
};

int main() {
    Point a{1, 2};
    std::cout << a.x << ", " << a.y << '\n';
}

聚合类型是下面的类型之一:

C++20 的更新

增加了对位域的支持

#include <iostream>

struct CompatStruct {
  int type : 4 {1};
  int flag : 4 {2};
};

int main() {
  CompatStruct t;
  std::cout << t.type << '\n';
  std::cout << t.flag << '\n';
}

优缺点

优点:

缺点

C++17 静态数据成员初始化

在 C++17 之前, 静态成员变量的初始化必须在类的定义外部进行, 例如: 头文件:

struct Position {
  static int unit;
};

实现文件:

int Position::unit = 0;

唯一的例外是一个静态常量整数变量, 你可以在一个地方声明和初始化:

class Position {
  static const int ImportantValue = 42;
};

在 C++17 中, 你可以使用内联变量来定义并初始化静态数据成员:

// a header file, C++17:
struct Position {
    static inline int unit = 0;

    // ...
};

编译器保证这个静态变量的定义只有一个, 无论哪个翻译单元包含了类声明. inline 变量仍然是静态类变量, 因此它们将在调用 main()函数之前初始化.

这个功能让开发只包含头文件的库变得更容易, 因为不需要为静态变量创建 CPP 文件, 或者使用一些技巧来保持它们在头文件中.

C++20 指定初始化(Designated Initializers)

基本用法

struct Embed {
  int id;
  int vendor;
};
struct Date {
  int year;
  int month;
  int day;
  Embed embed;
  static int mode;
};

int main() {
  Date a{.year = 2024, .month = 3, .day = 26};
  Date c{2024, 3, 26};  // 可读性较差
  Date e{
      .year = 2024,
      .month = 3,
      .day = 26,
      .embed{
          .id = 1,
          .vendor = 8086,
      },
  };  // ok
}

使用规则

使用规则如下:

一些错误示例:

struct Embed {
  int id;
  int vendor;
};

struct Date {
  int year;
  int month;
  int day;
  Embed embed;

  static int mode;
};

Date a{.mode = 10};              // error, mode is static!
Date b{.day = 1, .year = 2010};  // error, order not same!
Date c{2050, .month = 12};       // error, mix!
Date d{.embed.id = 1};           // error, nested!

使用指定初始化的优点:

可以使用 auto 推导的情况

对静态成员可以使用auto:

class Position {
    static inline auto answer = 42; // 推导出int
};

但是不能用于非静态变量:

class Position {
    auto x { 0 };   // error
    auto y { 10.5f }; // error
    auto z = int { 10 }; // error
};

类模板参数推导 CTAD(class template argument deduction)

自 C++17 起, 可用 CTAD 定义一个类模板对象, 而不指定模板参数.

std::pair p2(10.5, 42);
std::pair<double, int> p1(10.5, 42);

std::vector v1{1.1f, 2.2f, 3.3f};
std::vector<float> v2{1.1f, 2.2f, 3.3f};

using namespace std::string_literals;
std::vector s1{"hello"s, "world"s};
std::vector<std::string> s2{"hello", "world"};

这个新功能对于静态数据成员初始化是有效的, 但是对于非静态数据成员初始化是无效的.

class Type {
  static inline std::vector vec { 1, 2, 3, 4, 5, 6, 7}; // deduced vector<int>
};

class Type {
  std::vector vec { 1, 2, 3, 4, 5, 6, 7}; // error!
};

完整示例

#include <iostream>
#include <string>

struct Flags {
  unsigned int mode : 4 {0};
  unsigned int visible : 2 {1};
  unsigned int ext : 2 {0};
};

struct Position {
  inline static unsigned default_width = 100;
  inline static unsigned default_height = 100;

  unsigned width_{default_width};
  unsigned height_{default_height};
  Flags flags_{.mode = 2};
  std::string title_{"Default Position"};

  Position() = default;
  explicit Position(std::string title) : title_(std::move(title)) {}

  friend std::ostream& operator<<(std::ostream& os, const Position& w) {
    os << w.title_ << ": " << w.width_ << "x" << w.height_;
    return os;
  }
};

int main() {
  Position w("Super Position");
  std::cout << w << '\n';

  Position::default_width = 1920;
  Position w2("Position");
  std::cout << w2 << '\n';
}

总结

本文详细梳理了从 C++11 到 C++20 在类成员初始化方面的功能演进. C++11 引入了默认成员初始化, 委托构造函数和移动构造函数, 简化了对象初始化和资源管理. C++14 允许聚合类型使用默认初始化, C++17 通过内联变量简化了静态成员的初始化. C++20 新增了指定初始化和位域支持, 进一步提高了代码的可读性和灵活性. 这些特性使得类初始化更加直观, 高效, 但也需注意性能影响和兼容性问题. 通过合理利用这些新特性, 开发者可以编写出更简洁, 高效的 C++ 代码.