第七章 类

190 阅读28分钟

C++ 通过类自定义数据类型。通过定义新的类型来反映待解决问题中的各种概念,可以使程序更容易编写、调试和修改。本章主要关注数据抽象的重要性。

类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程/设计技术。类的接口包括用户能执行的操作,类的实现包括类的数据成员、负责接口实现的函数体、定义类所需的各种私有函数。类的接口与实现的分离通过封装来完成。封装后的类隐藏了实现细节,类的用户只能使用接口而无法访问具体实现的部分。

要想实现数据抽象和封装,必须先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,设计者负责考虑类的实现过程,使用者只需抽象地思考类型做了什么,无需关注类型的实现细节。

定义抽象数据类型

最好将类的用户和类的设计者这两个角色分开。设计类的接口时,应考虑如何才能让类易用;使用类时,不应顾及类的实现原理。

一个设计良好的类,既要有直观易用的接口,也必须具备高效的实现过程。

一个类拥有一系列成员函数(member function)。定义和声明成员函数的方式与普通函数类似。成员函数的声明必须在类的内部,定义则既可以在类的内部也可以在类的外部。

定义在类内部的函数是隐式 inline 函数。

struct Sales_data {
  std::string isbn() const { return bookNo; }
  Sales_data &combine(const Sales_data&);
  double avg_price() const;
  std::string bookNo;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);

成员函数体也是一个块。调用成员函数时,实际上是在替某个对象调用它。成员函数通过一个额外的、隐式定义的 this 参数来访问调用它的对象。this 是一个常量指针,调用成员函数时,用请求该函数的对象地址来初始化 this。成员函数内部,任何对类成员的直接访问都被看作对 this 的隐式引用,也可以在成员函数体内显式使用 this。禁止自定义名为 this 的参数或变量。

Sales_data total;
total.isbn();
// 等价于
Sales_data::isbn(&total);

默认情况下,this 是指向非常量类类型的常量指针,此时,不能将 this 绑定到常量对象上,也就不能在常量对象上调用该成员函数。如果成员函数体内不会改变 this 所指对象,可以把 this 设置为指向常量的指针来提高函数的灵活性。C++ 允许在成员函数的参数列表之后添加 const 关键字,表示 this 是一个指向常量的指针。这种成员函数称为常量成员函数(const member function)。

std::string Sales_data::isbn(const Sales_data *const this) const { return this->bookNo; }

常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

类本身也是一个作用域,类的成员函数的定义嵌套在类的作用域内。编译器分两步处理类:首先编译成员的声明,然后才处理成员函数体。因此,成员函数体中可以随意使用类中的其它成员,无须在意成员出现的顺序。

类外部定义成员函数时,必须和它的声明匹配且指明所属类名:

double Sales_data::avg_price() const {
  if (units_sold) {
    return revenue / units_sold;
  } else {
    return 0;
  }
}

Sales_data& Sales_data::combine(const Sales_data &rhs)
{
  units_sold += rhs.units_sold;
  revenue += rhs.revenue;
  return *this;
}

编译器根据函数名,可以知道这些代码位于类的作用域内。因此可以隐式使用类的成员。

一般来说,当定义的函数类似于某个内置运算符时,该函数的行为应该尽量模仿该运算符,包括返回值类型。

定义类相关的非成员函数

通常,类需要定义一些辅助函数,这些函数从概念上属于类接口的组成部分,但实际上并不属于类本身。非成员函数和普通函数定义一样,通常将声明和定义分离开。

一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

std::istream &read(std::istream &is, Sales_data &item)
{
  double price = 0.0;
  is >> item.bookNo >> item.units_sold >> price;
  item.revenue = price * item.units_sold;
  return is;
}

std::ostream &print(std::ostream &os, const Sales_data &item)
{
  os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
  return os;
}

Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
  Sales_data sum = lhs;
  sum.combine(rhs);
  return sum;
}

若要对流的内容读写,则必须使用普通引用,而非常量引用。

一般来说,执行输出任务的函数应尽量减少对格式的控制,将控制权交给用户。

默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

构造函数

构造函数(constructor)是一种特殊的成员函数,它负责初始化数据成员,只要创建类的对象,就会执行构造函数。构造函数与类同名。构造函数也有参数列表、函数体,而且可重载,但没有返回类型。构造函数不能声明为 const,创建一个 const 对象时,直到构造函数完成初始化过程,对象才能取得 “常量” 属性;构造函数在 const 对象的构造过程中可以向其写值。

类的默认初始化过程由默认构造函数(default constructor)来控制,默认构造函数无需任何实参。如果没有显式定义任何构造函数,编译器将隐式定义一个默认构造函数,称作合成的默认构造函数(synthesized default constructor)。对于大多数类来说,该函数按如下规则初始化数据成员:

  1. 若存在类内初始值,用它来初始化该成员;
  2. 否则,默认初始化该成员。

某些类不能依赖于合成的默认构造函数:

  1. 存在其他构造函数时,必须自定义一个默认构造函数,否则将没有默认构造函数。
  2. 合成的默认构造函数会对无初始值的数据成员执行默认初始化,可能产生错误操作。如果类包含有内置类型或复合类型的成员,则只有当这些成员全都被赋予类内初始值时,该类才适合使用合成的默认构造函数。
  3. 有时编译器不能为某些类合成默认构造函数,比如,某些成员是没有默认构造函数的类类型。
struct Sales_data {
  // 构造函数
  Sales_data() = default;
  Sales_data(const std::string &s): bookNo(s) {}
  Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) {}
  Sales_data(std::istream &);
  // 其它成员函数
  std::string isbn() const { return bookNo; }
  Sales_data &combine(const Sales_data&);
  double avg_price() const;
  // 数据成员
  std::string bookNo;
  unsigned units_sold = 0;
  double revenue = 0.0;
};

默认构造函数不接收任何实参。参数列表后面写上 = default 是在要求编译器生成构造函数,其作用与合成默认构造函数等同。= default 可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。如果出现在类的内部,也是默认内联。

构造函数初始值列表(constructor initialize list)可以为一个或几个数据成员赋初值。构造函数初始值是一个成员名字列表,每个成员名后面是成员初始值。构造函数初始值列表忽略某个数据成员时,将以与合成的默认构造函数相同的方式隐式初始化。

通常情况下,构造函数使用类内初始值来初始化数据成员。如果编译器不支持类内初始值,则所有构造函数都应显式初始化每个内置类型的成员。

构造函数不应轻易覆盖类内初始值,除非新值与原值不同。

在类外部定义构造函数时,必须指定类名。没有出现在构造函数初始值列表中的成员将通过相应的类内初始值初始化,或默认初始化。这些操作在构造函数最开始时执行。

Sales_data::Sales_data(std::istream &is)
{
  read(is, *this);
}

拷贝、赋值和析构

拷贝用于初始化变量、以值传参、以值返回对象等;赋值用于赋值运算符;销毁在对象不存在时执行,如:局部对象在创建它的块结束时将被销毁,当 vector 对象(或数组)销毁时,存储在其中的对象也会销毁。

如果没有自定义这些操作,编译器将会自动合成。一般来说,编译器生成的版本将对每个成员执行拷贝、赋值、销毁。有些类无法使用合成的版本,特别是,当类需要分配类对象之外的资源时,合成版本通常失效,比如:管理动态内存的类。使用 vectorstring 的类能避免分配和释放内存带来的复杂性。如果类包含 vectorstring 成员,则其拷贝、赋值、销毁的合成版本能正常工作。对含有 vector 成员的对象执行拷贝或赋值操作时,vector 类会设法拷贝或赋值成员中的元素;当销毁对象时,将销毁 vector 对象,也就是依次销毁 vector 中的每个元素,string 也是类似的。

访问控制与封装

C++ 使用访问说明符(access specifiers)加强类的封装性:

  • public 说明符后面定义的成员可被整个程序访问,public 成员定义类的接口。
  • private 说明符后面定义的成员可被类的成员函数访问,但不能被使用该类的代码访问,private 部分封装了类的实现细节。

一个类可以包含 0 个或多个访问说明符,访问说明符出现的次数没有严格限制。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾为止。

classstruct 关键字之间唯一的区别是,两者的默认访问权限不同。类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。对于 struct,在第一个访问说明符之前定义的成员是 public;对于 class,这些成员是 private

class Class_name {
public:
  // ...
private:
  // ...
};

友元

类允许其他类或函数访问它的非公有成员,方法是令其他类或函数成为它的友元(friend)。友元通过在类内以 friend 关键字开头的声明语句来指定。友元声明只能在类定义的内部,但是具体位置不限,友元不是类的成员,它不受访问说明符约束。

一般来说,最好在类定义开始或结束前的位置集中声明友元。

class Class_name {
friend declaration1;
// ...
}

declaration1;

封装有两个重要优点:

  • 确保用户代码不会无意间破坏封装对象的状态。
  • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。

尽管类的定义发生改变时无须更改用户代码,但使用该类的源文件必须重新编译。

类内友元声明只是为友元指定了访问权限,用户要调用友元,必须在类的外部专门对其进行声明。为使友元对类的用户可见,通常把友元声明与类本身放置在同一个头文件中。虽然有些编译器允许在尚无类外友元声明的情况下就调用它,但最好提供一个类外友元声明,避免依赖于编译器。

类的其它特性

类成员

类型成员

除了数据和函数成员之外,类还可以自定义某种类型在类中的别名。这些类型名同样受访问说明符的限制。

class Screen {
public:
  using pos = std::string::size_type;
};

类型成员可以通过 typedefusing 来定义,而且必须先定义后使用,因此类型成员通常出现在类开头的位置。

内联成员函数

类中常有一些规模较小的函数适合声明成内联函数。定义在类内部的成员函数是隐式 inline 的。可以在类内部将 inline 作为声明的一部分来显式声明成员函数,也可以在类外部用 inline 来修饰函数的定义。

class Screen {
public:
  using pos = std::string::size_type;
  Screen() = default;
  Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht * wd, c) {};
  char get() const { return contents[cursor]; }
  inline char get(pos r, pos c) const;
  Screen &move(pos r, pos c);
private:
  pos cursor = 0;
  pos height = 0, width = 0;
  std::string contents;
};

char Screen::get(pos r, pos c) const {
  return contents[r * width + c];
}

inline Screen &Screen::move(pos r, pos c) {
  cursor = r * width + c;
  return *this;
}

在声明和定义的地方同时说明 inline 也是合法的,但没有必要。最好只在类外部定义的地方说明 inline,这让类更容易理解。

inline 成员函数应该和相应的类定义在同一个头文件中。

成员函数也可以重载,只要函数间在参数数量、类型上有所区别即可。成员函数的函数匹配过程与非成员函数类似。

可变数据成员

可变数据成员(mutable data member)通过 mutable 关键字声明,它永远不会是 const,即使它是 const 对象的成员。const 成员函数可以改变可变成员的值。

class Screen {
public:
  void some_member() const;
private:
  mutable size_t access_ctr = 0;
};

void Screen::some_member() const
{
  ++access_ctr;
  // some codes
}

类内初始值必须用 ={}

返回 *this 的成员函数

如果成员函数返回对 *this 的引用,则可以通过链式调用连续操作同一个对象。

class Screen {
public:
// ...
  Screen &set(char c);
  Screen &set(pos ht, pos wd, char c);
// ...
};
Screen myScreen;
myScreen.move(4, 0).set('#');

一个 const 成员函数如果以引用形式返回 *this,则返回类型将是常量引用。

基于 const 的重载

const 成员函数也可以重载,以根据所作用的对象来决定返回值是否为常量。

class Screen {
public:
  Screen &display(std::ostream &os) {
    do_display(os);
    return *this;
  }
  const Screen &display(std::ostream &os) const {
    do_display(os);
    return *this;
  }
private:
  void do_display(std::ostream &os) const { os << contents; }
};

public 代码使用小的 private 功能函数,有以下原因:

  • 避免在多处使用同样的代码。
  • 随着类的规模发展,函数可能变得更复杂。
  • 针对该函数调试方便。
  • 类内部定义的函数是隐式 inline 的,不会增加额外的运行时开销。

在实践中,设计良好的 C++ 代码常包含大量的类似的小函数。调用这些函数,可以完成一组其它函数的实际工作。

类类型

每个类都定义了唯一的类型。

即使两个类的成员列表完全一致,它们也是不同的类型。

类名可以作为类型的名字使用,直接指向类类型;或者把类名跟在关键字 classstruct 后面,这种方式从 C 语言继承而来,在 C++ 语言中也是合法的。

// 以下两行等价
Sales_data item;
class Sales_data item;

类可以只声明,暂不定义:

class Screen;

这种声明有时称为前向声明(forward declaration),它向程序中引入名字并指定是一种类类型。在声明之后、定义之前,类类型是一个不完全类型(incomplete type),此时只知道它是一个类类型,但不清楚包含哪些成员。

不完全类型只能在非常有限的情景下使用:

  • 可以定义指向这种类型的指针或引用;
  • 可以声明(但不能定义)以不完全类型作为参数或返回类型的函数。

对于一个类来说,创建对象之前,必须先定义这个类。类必须先被定义,然后才能用引用或指针访问其成员。

类被定义之后,编译器才知道需要多少存储空间,数据成员才能声明成这种类类型。所以一个类的成员类型不能是该类自身。但是,类名出现之后,就已经声明,因此类允许包含指向自身类型的引用或指针。

友元

一个类可以把其它类或其它类(已经定义过)的成员函数声明成友元。友元函数能定义在类的内部,这样的函数是隐式内联的。

class Screen {
  friend class Window_mgr;
// ...
}
class Window_mgr {
public:
  using ScreenIndex = std::vector<Screen>::size_type;
  void clear(ScreenIndex);
private:
  std::vector<Screen> screens{Screen(24, 80, ' ')};
};

void Window_mgr::clear(ScreenIndex i)
{
  Screen &s = screens[i];
  s.contents = std::string(s.contents.size(), ' ');
}

友元类的成员函数可以访问当前类的所有成员。友元关系没有传递性,一个类的友元的友元不会自动成为该类的友元,必须在当前类中明确声明友元才行。

每个类只负责控制自己的友元类或友元函数。

class Screen {
  friend void Window_mgr::clear(ScreenIndex);
// ...
}

声明成员函数为友元时,必须仔细组织程序结构以满足声明和定义的彼此依赖关系。比如:友元函数要访问当前类的私有成员时,必须先在当前类内声明友元;当前类内将成员函数声明为友元时,必须先在友元类中声明该成员函数。

重载函数仍是不同的函数。如果一个类想把一组重载函数声明成友元,需对每个函数分别声明。

将类和非成员函数声明为友元之前,不要求必须先声明这些类和函数。一个名字第一次出现在友元声明中时,隐式假定该名字在当前作用域中可见。然而,友元本身不一定真的声明在当前作用域中。就算在类内部定义该函数,也必须在类外部提供相应声明才能使函数可见。类内友元声明只影响访问权限,没有普通意义上声明的作用。

struct X {
  friend void f() { /* */ }
}

void f();

类的作用域

类有自己的作用域。类的作用域之外,普通的数据和函数成员只能由对象、引用或指针使用成员访问运算符 .-> 访问。类的类型成员使用作用域运算符 :: 访问。

一个类就是一个作用域,因此在类的外部定义成员函数时必须同时提供类名和函数名。一旦遇到了类名,定义的剩余部分就在类的作用域内,包括参数列表和函数体,就可以直接使用类的其它成员而无须再次授权。函数的返回类型通常出现在函数名之前,因此定义在类外部的成员函数的返回类型在类的作用域外,所以必须指明类名。

名字查找与类的作用域

名字查找(name lookup)就是寻找与所用名字最匹配的声明的过程。一般情况下,名字按作用域查找。而类的定义则分两步处理:

  1. 首先,编译成员的声明;
  2. 直到类全部可见后才编译函数体。

这种两阶段的方式处理类可以简化类代码的组织方式,使得可以在成员函数体内使用类中定义的任何名字。但是声明中使用的名字,包括返回类型或参数列表中使用的名字,都必须在使用前确保名字可见。

typedef double Money;
string bal;

class Account {
public:
  Money balance() { return bal; }
private:
  Money bal;
};

在类中,如果成员使用了外层作用域中的某个代表类型的名字,则类不能在之后重新定义该名字。即使重定义的类型和被覆盖的类型一致,也不允许。编译器不为这种错误负责。

类型名的定义通常出现在类的开始处,从而保证所有使用该类型的成员都出现在类名的定义之后。

typedef double Money;

class Account {
public:
  Money balance() { return bal; }
private:
  typedef double Money; // 错误:不能重定义 Money
  Money bal;
};

成员函数体内使用的名字按照如下顺序解析:

  1. 成员函数内查找该名字的声明;
  2. 查找该类中的所有成员;
  3. 在成员函数定义之前的作用域内查找。
int height;

class Screen {
public:
  typedef std::string::size_type pos;
  void dummy_fcn(pos height) {
    cursor = width * height; // 参数 height
    cursor = width * Screen::height; // height 成员
    cursor = width * this->height; // height 成员
    cursor = width * ::height; // 最外层作用域中 height
  }
  void setHeight(pos);
private:
  pos cursor = 0;
  pos height = 0, width = 0;
};

Screen::pos verify(Screen::pos);

void Screen::setHeight(pos var) {
  // var:参数
  // height:成员
  // verify:全局函数
  height = verify(var);
}

成员被隐藏时,仍然可以通过加上类的名字或显式使用 this 指针来强制访问该成员。对于最外层作用域中的名字,可以显式使用作用域运算符 :: 来访问。通常,不建议参数和成员名字相同。

成员定义在类外部时,名字查找中的第三步要考虑的是成员函数定义前的全局作用域中的声明,而不仅是类定义之前。

构造函数

构造函数初始值列表

如果没有在构造函数初始值列表中显式初始化成员,则该成员将在构造函数体之前执行默认初始化。和定义变量时类似,对象数据成员的初始化和赋值也有区别。

如果成员是引用、const,或某种未定义默认构造函数的类类型时,必须要初始化。初始化唯一的时机就是通过构造函数初始值列表。

class ConstRef {
public:
  ConstRef(int ii);
private:
  int i;
  const int ci;
  int &ri;
};

ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }

很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者先初始化再赋值。除了效率问题外更重要的是,一些数据成员必须被初始化。建议总是使用构造函数初始值。

构造函数初始值中每个成员只能出现一次。构造函数初始值列表只说明用于初始化成员的值,不限定初始化的具体执行顺序。成员按类定义中出现的顺序初始化,而与初始值列表中的顺序无关。如果一个成员使用另一个成员来初始化,那么两个成员间的初始化顺序就很关键。

class X {
  int i;
  int j;
public:
  // i 在 j 之前被初始化
  X(int val): j(val), i(j) {}
};

最好令构造函数初始值顺序与成员声明顺序保持一致,最好使用构造函数的参数初始化成员,尽量避免使用某些成员初始化其它成员。

构造函数也可以使用默认实参,从而将多个构造函数合并成一个。如果一个构造函数为所有参数都提供了默认实参,那么它实际上也定义了默认构造函数。

class Sales_data {
public:
  Sales_data(const std::string &s = ""): bookNo(s) { }
  Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) { }
  Sales_data(std::istream &is);
// ...
};

委托构造函数

委托构造函数(delegating constructor)使用所属类的其它构造函数执行它自己的初始化过程。委托构造函数也有一个成员初始值列表和一个函数体。成员初始值列表中只有一个带参数列表的类名,参数列表必须和类中另一个构造函数匹配。

class Sales_data {
public:
  Sales_data(): Sales_data("", 0, 0) {}
  Sales_data(const std::string &s): Sales_data(s, 0, 0) {}
  Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) {}
  Sales_data(std::istream &is): Sales_data() { read(is, *this); }
// ...
};

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行,然后才将控制权交给委托者的函数体。

默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。以下是默认初始化的情况:

  • 块作用域中无初始值定义非静态变量或数组时;
  • 一个类本身包含类类型的成员,且使用合成的默认构造函数时;
  • 类类型的成员没有在构造函数初始值列表中显式初始化。

值初始化的情况:

  • 数组初始化的过程中,提供的初始值数量少于数组大小;
  • 无初始值定义局部静态变量时;
  • 使用形如 T() 的表达式显式请求值初始化时(比如 vector),其中 T 是类型名。

针对上述情况,必须提供一个默认构造函数。实际中,如果定义了其它构造函数,最好也提供一个默认构造函数。

使用默认构造函数定义对象时,不能带有 ()

Sales_data obj; // 默认初始化一个对象
Sales_data fn(); // 声明一个函数

隐式的类类型转换

类也能定义隐式转换规则。能通过一个实参调用的构造函数(包括带有默认实参的构造函数)定义了一条从构造函数的参数类型向类类型隐式转换的规则,这种构造函数称为转换构造函数(converting constructor)。

string null_book = "9-999-99";
Sales_data item;

item.combine(null_book);
item.combine("9-999-99"); // 错误:需要两步隐式转换
item.combine(string("9-999-99"));
item.combine(Sales_data("9-999-99"));

编译器只会自动执行一步类型转换。类型转换时,先以该参数调用相应的构造函数自动创建一个临时对象,用完后将其丢弃。

是否使用类类型转换依赖于对用户使用该转换的看法。

将构造函数声明为 explicit 可以阻止相应的隐式转换。

class Sales_data {
public:
  // 构造函数成员
  Sales_data() = default;
  Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p * n) { }
  explicit Sales_data(const std::string &s): bookNo(s) { }
  explicit Sales_data(std::istream&);
// ...
};

Sales_data item1(null_book);
Sales_data item2 = null_book; // 错误:explicit 构造函数不能用于拷贝初始化

关键字 explicit 只对转换构造函数有效。只能在类内声明构造函数时使用 explicit 关键字,类外定义时不应重复。使用 = 执行拷贝初始化时将发生隐式转换。

使用 explicit 关键字声明的构造函数,只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

explicit 构造函数不能用于隐式转换,但可用于显式地强制转换。

item.combine(Sales_data(null_book)); // 显式构造
item.combine(static_cast<Sales_data>(cin)); // 显式强制转换

标准库中含有单参数构造函数的类:

  • 接收一个单参数 const char*string 构造函数不是 explicit
  • 接收一个容量参数的 vector 构造函数是 explicit 的。

聚合类

满足如下条件的类称为聚合类(aggregate class):

  • 所有成员都是 public
  • 没有定义任何构造函数;
  • 没有类内初始值;
  • 没有基类,也没有 virtual 函数。

聚合类让用户可以直接访问其成员,并且有特殊的初始化语法。

struct Data {
  int ival;
  string s;
};

Data val1 = { 1, "Anna" };

可以使用 {} 成员初始值列表初始化聚合类的数据成员。初始值顺序必须和声明顺序一致。如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数不能超过类的成员数量。

显式地初始化类的成员存在三个明显缺点:

  • 要求类的所有成员都是 public
  • 将正确初始化每个成员的工作交给类的用户,这样的初始化过程容易出错;
  • 添加或删除一个成员之后,所有初始化语句都需要更新。

字面值常量类

类也可以是字面值类型。字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式的常量成员函数。

数据成员都是字面值类型的聚合类是字面值常量类。一个非聚合类,若满足如下要求,则也是字面值常量类:

  • 数据成员都是字面值类型;
  • 类必须至少包含一个 constexpr 构造函数;
  • 如果数据成员含有类内初始值:
    • 内置类型成员的初始值必须是常量表达式
    • 类类型成员的初始值必须使用自身的 constexpr 构造函数
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr 构造函数可以声明为 =default 的形式;其它情况,constexpr 构造函数体一般为空。constexpr 构造函数必须初始化所有数据成员,初始值要么使用 constexpr 构造函数,要么是一条常量表达式。constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回值。

class Debug {
public:
  constexpr Debug(bool b = true): hw(b), io(b), other(b) {}
  constexpr Debug(bool h, bool i, bool o): hw(h), io(i), other(o) {}
  constexpr bool any() { return hw || io || other; }
  void set_hw(bool b) { hw = b; }
  void set_io(bool b) { io = b; }
  void set_other(bool b) { other = b; }
private:
  bool hw;
  bool io;
  bool other;
};

constexpr Debug prod(false);

类的静态成员

声明

使用关键字 static 可以声明静态成员。静态成员可以是 public,也可以是 private。类的静态成员存在于任何对象之外。静态成员函数不与任何对象绑定,它们不包含 this 指针。静态成员函数不能声明成 const,而且也不能在函数内使用 this 指针,不能在函数内调用非静态成员。

class Account {
public:
  void calculate() { amount += amount * interestRate; }
  static double rate() { return interestRate; }
  static void rate(double);
private:
  std::string owner;
  double amount;
  static double interestRate;
  static double initRate();
};

使用

静态成员可以直接使用作用域运算符访问,也可以使用类的对象、引用或指针来访问。成员函数内不需要作用域运算符就可以直接使用静态成员。

double r = Account::rate();
Account ac1;
double r1 = ac1.rate();

定义

定义静态成员函数可以在类内部,也可以在类外部。类外部定义静态成员时,不能重复 static 关键字,该关键字只能出现在类内部声明语句中。

静态数据成员不属于任何一个对象,所以它们并不在创建类的对象时定义,不由构造函数初始化。一般来说,不能在类内部初始化静态成员。必须在类外部定义并初始化每个静态成员。和全局变量类似,静态数据成员定义在任何函数之外;一旦定义,就一直存在于程序的整个生命周期中。

为确保对象只定义一次,可将静态数据成员的定义和其他非内联函数的定义放在同一个文件中。

void Account::rate(double newRate)
{
  interestRate = newRate;
}

double Account::interestRate = initRate();

类内初始化

如果静态成员是字面值常量类型的 constexpr,则可以为该成员提供 const 类内初始值。初始值必须是常量表达式,而且该成员可用于任何适合常量表达式的地方。

如果某个静态成员的应用场景只有编译器可以替换它的值的情况,则一个初始化的 constconstexpr static 不需要分别定义;反之,如果用于值不能替换的场景中,则该成员必须有一条定义语句,比如将 constexpr 静态成员传给一个参数类型为常量引用的函数。

如果类内部已经提供了初始值,则成员定义不能再指定初始值了。

即使一个常量静态数据成员在类内初始化了,通常也应该在类外定义该成员。

class Account {
private:
  static constexpr int period = 30;
  double daily_tbl[period];
};

constexpr int Account::period;

只适用静态成员的特殊场景

静态数据成员可以是不完全类型,特别地,静态数据成员的类型可以是它所属的类类型。静态成员可以作为默认实参,而非静态成员则不能。

class Screen {
public:
  // ...
  Screen &clear(char = bg);
private:
  static Screen mem;
  static const char bg;
};