第16章 类(Classes)
目录
16.2.7 类内初始化器(In-Class Initializers)
16.2.8 类内函数定义(In-Class Function Definitions)
16.2.9.1 常量成员函数(Constant Member Functions)
16.2.9.2 物理常量和逻辑常量(Physical and Logical Constness)
16.2.9.4 通过间接方式实现可变性(Mutability through Indirection)
16.1 引言
C++ 类是用于创建新类型的工具,新类型的使用方式与内置类型一样方便。此外,派生类(第 20 章第 3.2.4 节)和模板(第 23 章第 3.4 节)允许程序员表达类之间的关系(层次和参数),并利用此类关系。
类型是概念(思想、观念等)的具体表示。例如,C++ 内置类型 float及其运算 +、−、∗ 等提供了实数数学概念的具体近似值。类是用户定义的类型。我们设计一种新类型来提供内置类型中没有直接对应项的概念的定义。例如,我们可能在处理电话的程序中提供类型 Trunk_line,为视频游戏提供类型 Explosion,或为文本处理程序提供类型 list<Paragraph>。提供与应用程序概念紧密匹配的类型的程序往往比不提供此类类型的程序更容易理解、更容易推理和更容易修改。一组精心挑选的用户定义类型也会使程序更简洁。此外,它使多种代码分析成为可能。特别是,它使编译器能够检测出对象的无效使用,而这些无效使用原本只能通过详尽的测试才能发现。
定义新类型的基本思想是将实现的附带细节(例如,用于存储该类型对象的数据布局)与正确使用所必需的属性(例如,可以访问数据的完整函数列表)分开。这种分离最好通过特定接口引导数据结构及其内部管理例程的所有使用来表达。
本章重点介绍相对简单的“具体”用户定义类型,这些类型在逻辑上与内置类型没有太大区别:
§16.2 类基础:介绍定义类及其成员的基本工具。
§16.3 具体类:讨论优雅、高效的具体类的设计。
以下章节将更详细地介绍抽象类和类层次结构:
第 17 章:构造、清理、复制和移动——表述了控制类对象初始化的各种方法、如何复制和移动对象以及如何提供在对象被销毁(例如,超出范围)时要执行的“清理操作”。
第 18 章:运算符重载——解释了如何为用户定义类型定义一元和二元运算符(例如 +、∗ 和 !)以及如何使用它们。
第 19 章:特殊运算符——考虑如何定义和使用运算符(例如 []、()、−>、new),这些运算符是“特殊的”,因为它们通常以不同于算术和逻辑运算符的方式使用。特别是,本章展示了如何定义字符串类。
第 20 章:派生类——介绍支持面向对象编程的基本语言特性。涵盖基类和派生类、虚函数和访问控制。
第 21 章:类层次结构——重点介绍如何使用基类和派生类来围绕类层次结构概念有效地组织代码。本章的大部分内容都用于讨论编程技术,但也涵盖了多重继承(具有多个基类的类)的技术方面。
第 22 章:运行时类型信息——描述了显式导航类层次结构的技术。特别是,介绍了类型转换操作 dynamic_cast 和 static_cast,以及根据对象的一个基类(typeid)确定其类型的操作。
16.2 类的基础知识
以下是对类的一个简要概述:
• 类是用户定义的类型。
• 类由一组成员组成。最常见的成员类型是数据成员和成员函数。
• 成员函数可以定义初始化(创建)、复制、移动和清理(析构)的含义。
• 使用 .(点)访问对象成员,使用 −>(箭头)访问指针成员。
• 可以为类定义运算符,例如 +、! 和 []。
• 类是包含其成员的命名空间(译注:类也可看作是一个命名空间)。
• 公共成员提供类的接口,私有成员提供实现细节。
• 一个结构体(struct)是一个成员默认为公共的类。
例如:
class X {
private: //表示 (实现)是私有的
int m;
public: //用户接口是公有的
X(int i =0) :m{i} { } // 构造函数 (初始化数据成员m)
int mf(int i) // 一个成员函数
{
int old = m;
m = i; // 设置一个新值
return old; // 返回旧值
}
};
X var {7}; // 类型X的变量, 初始化为7
int user(X var, X∗ ptr)
{
int x = var.mf(7); // 使用 .(点)访问
int y = ptr−>mf(9); //使用-> (箭头)访问
int z = var.m; // 错误: 不可访问私有成员
}
以下各节将对此进行扩展并给出理由。风格是教程式的:逐步发展想法,细节留待以后再阐述。
16.2.1 成员函数
考虑使用结构体(§2.3.1,§8.2)实现日期的概念,以定义日期的表示形式和一组用于操作此类型变量的函数:
struct Date { // 表示
int d, m, y;
};
void init_date(Date& d, int, int, int); // initialize d
void add_year(Date& d, int n); // add n years to d
void add_month(Date& d, int n); // add n months to d
void add_day(Date& d, int n); // add n days to d
数据类型 Date 和这些函数之间没有明确的联系。可以通过将函数声明为成员来建立这种联系:
struct Date {
int d, m, y;
void init(int dd, int mm, int yy); // initialize
void add_year(int n); // add n years
void add_month(int n); // add n months
void add_day(int n); // add n days
};
在类定义中声明的函数(struct是一种类;§16.2.4)称为类的成员函数,只能使用结构成员访问的标准语法(§8.2)针对适当类型的特定变量调用。例如:
Date my_birthday;
void f()
{
Date today;
today.init(16,10,1996);
my_bir thday.init(30,12,1950);
Date tomorrow = today;
tomorrow.add_day(1);
// ...
}
因为不同的结构体可以有同名的成员函数,所以在定义成员函数时必须指定结构体名称:
void Date::init(int dd, int mm, int yy)
{
d = dd;
m = mm;
y = yy;
}
在成员函数中,成员名称可以在不显式引用对象的情况下使用。在这种情况下,名称指的是调用该函数的对象的成员。例如,当为today调用 Date::init() 时,m=mm 分配给 today.m。另一方面,当为 my_birthday 调用 Date::init() 时,m=mm 分配给 my_birthday.m。类成员函数“知道”它被调用的对象。但有关static成员的概念,请参阅 §16.2.12。
16.2.2 默认复制
默认情况下,对象是可以复制的。具体来说,类对象可以用其类的对象的副本来初始化。例如:
Date d1 = my_birthday; // 复制初始化
Date d2 {my_birthday}; // 复制初始化
默认情况下,类对象的副本是每个成员的副本。如果该默认行为不是类 X 所需的行为,则用户可以为类可以提供更合适的行为(§3.3,§17.5)。
类似地,类对象默认可以通过赋值进行复制。例如:
void f(Date& d)
{
d = my_birthday;
}
再次强调,默认语义是成员逐一复制。如果这对于类 X 来说不是正确的选择,用户可以定义适当的赋值运算符(§3.3,§17.5)。
16.2.3 访问控制
上一小节中 Date 的声明提供了一组用于操作 Date 的函数。但是,它并未指定这些函数应该是唯一直接依赖于 Date 表示的函数,也是唯一直接访问 Date 类对象的函数。此限制可以通过使用class而不是struct来表达:
class Date {
int d, m, y;
public:
void init(int dd, int mm, int yy); // initialize
void add_year(int n); // add n years
void add_month(int n); // add n months
void add_day(int n); // add n days
};
public 标签将类主体分成两部分。第一部分 private 中的名称只能由类的成员函数使用。第二部分 public 构成类对象的公共接口。struct 只是一个类,其成员默认为公共(§16.2.4);成员函数可以像以前一样定义和使用。例如:
void Date::add_year(int n)
{
y += n;
}
但是,非成员函数禁止使用类的私有成员。例如:
void timewarp(Date& d)
{
d.y −= 200; // 错: Date::y 是私有的
}
init() 函数现在必不可少,因为将数据设为私有迫使我们提供一种初始化成员的方法。例如:
Date dx;
dx.m = 3; // 错: m 是私有的
dx.init(25,3,2011); // OK
将对数据结构的访问限制为明确声明的函数列表,可获得多种好处。例如,任何导致 Date 取无效值(例如,2016 年 12 月 36 日)的错误都必然由成员函数中的代码引起。这意味着调试的第一阶段——局部化——在程序运行之前就已完成。这是一般观察的一个特例,即对 Date 类型行为的任何更改都可以且必须通过更改其成员来实现。特别是,如果我们更改类的表示形式,我们只需要更改成员函数即可利用新的表示形式。用户代码仅直接依赖于公共接口,无需重写(尽管可能需要重新编译)。另一个优点是,潜在用户只需检查成员函数的定义即可学习使用类。一个更微妙但最重要的优势是,专注于设计一个好的接口可以让你写出更好的代码,因为原本用于调试的思想和时间都花在了正确使用相关的问题上。
私有数据的保护依赖于对类成员名称使用的限制。因此,可以通过地址操作(§7.4.1)和显式类型转换(§11.5)来规避它。但这当然是作弊。C++ 可以防止意外,而不是故意规避(欺诈)。只有硬件才能提供完美的保护,防止通用语言被恶意使用,而这在现实系统中也很难做到。
16.2.4 class和struct
结构
class X { ... };
称为类定义;它定义了一个称为X的类型。由于历史的原因,类的定义通常称为类声明。此外,与非定义的声明一样,类定义可以使用 #include 在不同的源文件中复制,而不会违反一次定义规则(§15.2.3)。
根据定义,struct也是类,只不过其成员默认是公有的;即,
struct S { /* ... */ };
是
class S { public:/* ... */ };
的缩写。S 的这两个定义是可交换的,但通常最好坚持一种风格。使用哪种风格取决于情况和品味。我倾向于将我认为是“只是简单的数据结构”的类称为 struct。如果我认为类是“具有不变量的正确类型”,我会使用class。构造函数和访问函数即使对于结构也非常有用,但作为简写而不是不变性的保证(§2.4.3.2、§13.4)。
默认情况下,类的成员函数是私有的:
class Date1 {
int d, m, y; // 默认为private
public:
Date1(int dd, int mm, int yy);
void add_year(int n); // add n years
};
但是,我们也可以使用访问指定符 private: 来表示以下成员是私有的,就像 public: 表示以下成员是公共的一样:
struct Date2 {
private:
int d, m, y;
public:
Date2(int dd, int mm, int yy);
void add_year(int n); // add n years
};
除了名称不同之外,Date1和Date2是等效的。
在类中,不要求首先声明数据。事实上,将数据成员放在最后往往是有意义的,以强调提供公共用户接口的功能。例如:
class Date3 {
public:
Date3(int dd, int mm, int yy);
void add_year(int n); // add n years
private:
int d, m, y;
};
在实际代码中,公共接口和实现细节通常比教程示例中的更为广泛,我通常更喜欢用于 Date3 的风格。
访问指定符可以在单个类声明中使用多次。例如:
class Date4 {
public:
Date4(int dd, int mm, int yy);
private:
int d, m, y;
public:
void add_year(int n); // add n years
};
但是,如果像 Date4 中那样有多个公共部分,往往会很混乱,并且可能会影响对象布局(§20.5)。如果有多个私有部分,也会很混乱。但是,允许类中有多个访问指定符对于机器生成的代码很有用。
16.2.5 构造函数
使用诸如 init() 之类的函数来为类对象提供初始化是不优雅且容易出错的。由于没有任何地方规定必须初始化对象,程序员可能会忘记这样做——或者两次这样做(通常结果同样糟糕)。更好的方法是允许程序员声明一个明确用于初始化对象的函数。由于这样的函数构造给定类型的值,因此它被称为构造函数。构造函数通过与类本身同名来识别。例如:
class Date {
int d, m, y;
public:
Date(int dd, int mm, int yy); // constructor
// ...
};
当类具有构造函数时,该类的所有对象都将通过构造函数调用进行初始化。如果构造函数需要参数,则必须提供这些参数:
Date today = Date(23,6,1983);
Date xmas(25,12,1990); // 缩写形式
Date my_birthday; //错: 缺失初始化
Date release1_0(10,12); // 错: 缺失第三个参数
由于构造函数为类定义了初始化,因此我们可以使用 {} 初始化器表示法:
Date today = Date {23,6,1983};
Date xmas {25,12,1990}; // 缩写形式
Date release1_0 {10,12}; // 错: 缺失第三个参数
我建议使用 {} 符号而不是 () 符号进行初始化,因为它可以明确说明正在执行的操作(初始化),避免一些潜在的错误,并且可以一致地使用(§2.2.2,§6.3.5)。在某些情况下必须使用 () 符号(§4.4.1,§17.3.2.1),但这种情况很少见。
通过提供多个构造函数,我们可以提供多种初始化类型对象的方法。例如:
class Date {
int d, m, y;
public:
// ...
Date(int, int, int); // day, month, year
Date(int, int); // day, month, today’s year
Date(int); //day,今天的月和年
Date(); //默认日期: 今天
Date(const char∗); // 以字符串形式表示时期
};
构造函数遵循与普通函数相同的重载规则(§12.3)。只要构造函数的参数类型足够不同,编译器就可以选择正确的构造函数供使用:
Date today {4}; // 4, today.m, today.y
Date july4 {"July 4, 1983"};
Date guy {5,11}; // 5, November, today.y
Date now; // 默认初始化为今天
Date start {}; // 默认初始化为今天
Date 示例中构造函数的激增很典型。在设计类时,程序员总是忍不住要添加一些功能,因为有人可能需要它们。需要花更多心思来仔细决定真正需要哪些功能,并只包含这些功能。然而,这种额外的思考通常会使程序更小、更易理解。减少相关函数数量的一种方法是使用默认参数(§12.2.5)。对于 Date,可以为每个参数赋予一个默认值,解释为“选择默认值:today”。
class Date {
int d, m, y;
public:
Date(int dd =0, int mm =0, int yy =0);
// ...
};
Date::Date(int dd, int mm, int yy)
{
d = dd ? dd : today.d;
m = mm ? mm : today.m;
y = yy ? yy : today.y;
// 检验Date有效性
}
当使用参数值来表示“挑选默认值”时,所选值一定超出参数的可能值集。对于day和month,显然如此,但对于year,零可能不是一个明显的选择。幸运的是,欧洲日历上没有零年;公元 1 年(year==1)紧随公元前 1 年(year == −1)之后。
或者,我们可以直接使用默认值作为默认参数:
class Date {
int d, m, y;
public:
Date(int dd =today.d, int mm =today.m, int yy =today.y);
// ...
};
Date::Date(int dd, int mm, int yy)
{
// check that the Date is valid
}
但是,我选择使用 0 来避免在 Date 的接口中构建实际值。这样,我们以后就可以选择改进默认值的实现。
请注意,通过保证对象的正确初始化,构造函数大大简化了成员函数的实现。有了构造函数,其他成员函数就不再需要处理未初始化数据的可能性(§16.3.1)。
16.2.6 explicit型构造函数
默认情况下,由单个参数调用的构造函数充当从其参数类型到其类型的隐式转换。例如:
complex<double> d {1}; // d=={1,0} (§5.6.2)
这种隐式转换非常有用。复数就是一个例子:如果我们忽略虚部,我们会得到实轴上的复数。这正是数学所要求的。然而,在许多情况下,这种转换可能是造成混乱和错误的重要原因。考虑Date:
void my_fct(Date d);
void f()
{
Date d {15}; // 合理的: x becomes {15,today.m,today.y}
// ...
my_fct(15); // 晦涩的
d = 15; //晦涩的
// ...
}
充其量,这是模糊的。无论我们的代码多么复杂,数字 15 和日期之间都没有明确的逻辑联系。
幸运的是,我们可以指定构造函数不用作隐式转换。使用关键字explicit声明的构造函数只能用于初始化和显式转换。例如:
class Date {
int d, m, y;
public:
explicit Date(int dd =0, int mm =0, int yy =0);
// ...
};
Date d1 {15}; // OK: 考虑了explicit
Date d2 = Date{15}; // OK: explicit
Date d3 = {15}; // 错: = 初始化不能隐式转换
Date d4 = 15; // 错: = 初始化不能隐式转换
void f()
{
my_fct(15); // 错: 参数传递不能隐式转换
my_fct({15}); // 错: 参数传递不能隐式转换
my_fct(Date{15}); // OK: explicit
// ...
}
带有 = 的初始化被视为复制初始化。在原则上,初始化器的副本被放入初始化对象中。但是,这种副本可能会被优化掉(省略),并且如果初始化器是右值(§6.4.1),则可以使用移动操作(§3.3.2,§17.5.2)。省略 = 会使初始化变得显式。显式初始化称为直接初始化。
在默认情况下,声明一个可以使用单个参数显式调用的构造函数。您需要一个不这样做的充分理由(对于复杂情况而言)。如果您定义隐式构造函数,最好记录您的理由,否则维护人员可能会怀疑您健忘(或无知)。
如果构造函数被声明为explicit并且在类之外定义,则该explicit不能重复(译注:指的是定义时不能再使用explicit关键字):
class Date {
int d, m, y;
public:
explicit Date(int dd);
// ...
};
Date::Date(int dd) { /* ... */ } // OK
explicit Date::Date(int dd) { /* ... */ } // error
大多数explicit很重要的示例都涉及单个构造函数参数。但是,explicit对于具有零个或多个参数的构造函数也很有用。例如:
struct X {
explicit X();
explicit X(int,int);
};
X x1 ={}; //error : implicit
X x2 = {1,2}; // error : implicit
X x3{}; //OK: explicit
X x4 {1,2}; // OK: explicit
int f(X);
int i1 = f({}); // error : implicit
int i2 = f({1,2}); // error : implicit
int i3 = f(X{}); // OK: explicit
int i4 = f(X{1,2}); // OK: explicit
对于列表初始化(§17.3.4.3),保留直接初始化和复制初始化之间的区别。
16.2.7 类内初始化器(In-Class Initializers)
当我们使用多个构造函数时,成员初始化可能会变得重复。例如:
class Date {
int d, m, y;
public:
Date(int, int, int); // day, month, year
Date(int, int); // day, month, today’s year
Date(int); //day, today’s month and year
Date(); //default Date: today
Date(const char∗); // 字符串形式示的date
// ...
};
现在,每个构造函数都会初始化 d、m 和 y(除非它自己初始化自己)。例如:
Date::Date(int dd)
:d{dd}
{
// check that the Date is valid
}
这相当于:
Date::Date(int dd)
:d{dd}, m{today.m}, y{today.y}
{
// check that the Date is valid
}
16.2.8 类内函数定义(In-Class Function Definitions)
在类定义中定义的成员函数(而不是简单地在那里声明)被视为内联(§12.1.5)成员函数。也就是说,类内成员函数定义适用于小型、很少修改、经常使用的函数。就像它所属的类定义一样,在类中定义的成员函数可以使用 #include 在多个编译单元中复制。与类本身一样,成员函数的含义在任何其被 #include 的地方都必须相同(§15.2.3)。
一个成员可以引用其类的另一个成员,而不管该成员的定义位置如何(§6.3.4)。考虑:
class Date {
public:
void add_month(int n) { m+=n; } // 递增Date的 m
// ...
private:
int d, m, y;
};
也就是说,函数和数据成员声明与顺序无关。我也可以等效地写成:
class Date {
public:
void add_month(int n) { m+=n; } // 递增Date的 m
// ...
private:
int d, m, y;
};
inline void Date::add_month(int n) // 加n 个月份
{
m+=n; // 递增Date的 m
}
后一种风格通常用于保持类定义简单易读。它还提供了类的接口和实现的文本分离。
显然,我简化了 Date::add_month 的定义;仅仅添加 n 并希望求得一个好的日期太天真了(§16.3.1)。
16.2.9 易变性(Mutability)
我们可以将命名对象定义为常量或变量。换句话说,名称可以指包含不可变值(immutable)或可变值(mutable)的对象。由于准确的术语可能有点笨拙,我们最终将某些命名对象称为常量或更简短的 const 变量。无论这对以英语为母语的人听起来有多奇怪,这个概念都很有用,并且深深嵌入在 C++ 类型系统中。系统地使用不可变对象可以使代码更易于理解,更早发现更多错误,有时还可以提高性能。特别是,不变性是多线程程序中最有用的属性(§5.3,第 41 章)。
为了在内置类型的简单常量定义之外发挥作用,我们必须能够定义对用户定义类型的 const 对象进行操作的函数。对于独立函数,这意味着采用 const T& 参数的函数。对于类,这意味着我们必须能够定义对 const 对象进行操作的成员函数。
16.2.9.1 常量成员函数(Constant Member Functions)
到目前为止,Date 的定义提供了用于为 Date 赋值的成员函数。遗憾的是,我们没有提供检查 Date 值的方法。通过添加读取日期、月份和年份的函数,可以轻松解决这个问题:
class Date {
int d, m, y;
public:
int day() const { return d; }
int month() const { return m; }
int year() const;
void add_year(int n); // add n years
// ...
};
函数声明中(空)参数列表后的 const 表示这些函数不会修改日期的状态。
当然,编译器会捕获违反此承诺的意外尝试。例如:
int Date::year() const
{
return ++y; // 错 : 试图更改const 函数中的成员值
}
当 const 成员函数在其类之外定义时,需要使用 const 后缀:
int Date::year() // 错: 在成员函数类型中失丢const
{
return y;
}
换句话说,const 是 Date::day(),Date::month() 和 Date::year() 类型的一部分。
const 成员函数可由 const 对象和非 const 对象调用,而非 const成员函数只能由非 const 对象调用。例如:
void f(Date& d, const Date& cd)
{
int i = d.year(); // OK
d.add_year(1); // OK
int j = cd.year(); // OK
cd.add_year(1); // 错 : 不能改变一个const Date的值
}
16.2.9.2 物理常量和逻辑常量(Physical and Logical Constness)
有时,成员函数在逻辑上是 const,但它仍然需要更改成员的值。也就是说,对于用户来说,该函数似乎不会更改其对象的状态,但用户无法直接观察到的一些细节会被更新。这通常称为逻辑常量(logical constness)。例如,Date 类可能有一个返回字符串表示的函数。构造此表示可能是一个相对昂贵的操作。因此,保留一份副本是有意义的,这样重复的请求只会返回副本,除非 Date 的值已更改。对于更复杂的数据结构,缓存这样的值更常见,但让我们看看如何为 Date 实现它:
class Date {
public:
// ...
string string_rep() const; // string 表示
private:
bool cache_valid;
string cache;
void compute_cache_value(); // fill cache
// ...
};
从用户的角度来看,string_rep 不会改变其 Date 的状态,因此它显然应该是一个 const 成员函数。在另一方面,cache 和 cache_valid 成员必须偶尔更改,以使设计有意义。
这些问题可以通过强制类型转换(例如 const_cast (§11.5.2))来解决。但是,也有相当优雅的解决方案,不涉及类型规则。
16.2.9.3 mutable(易变)修饰符
我们可以将类的成员定义为mutable,这意味着即使在 const 对象中也可以修改它:
class Date {
public:
// ...
string string_rep() const; // string 表示
private:
mutable bool cache_valid;
mutable string cache;
void compute_cache_value() const; // fill (mutable) cache
// ...
};
现在我们可以用明显的方式定义 string_rep():
string Date::string_rep() const
{
if (!cache_valid) {
compute_cache_value();
cache_valid = true;
}
return cache;
}
现在,我们可以将 string_rep() 用于 const 和非 const 对象。例如:
void f(Date d, const Date cd)
{
string s1 = d.string_rep();
string s2 = cd.string_rep(); // OK!
// ...
}
16.2.9.4 通过间接方式实现可变性(Mutability through Indirection)
当只允许更改小对象表示的一小部分时,将成员声明为mutable是最合适的。更复杂的情况通常可以通过将变化的数据放在单独的对象中并间接访问来更好地处理。如果使用该技术,带缓存的string示例将变为:
struct cache {
bool valid;
string rep;
};
class Date {
public:
// ...
string string_rep() const; // string表示
private:
cache∗c; //构造函数中初始化
void compute_cache_value() const; // 填充cache所指
// ...
};
string Date::string_rep() const
{
if (!c−>valid) {
compute_cache_value();
c−>valid = true;
}
return c−>rep;
}
支持缓存的编程技术可以推广到各种形式的惰性求值。
请注意,const 不适用于(传递性地)通过指针或引用访问的对象。人类读者可能会将这样的对象视为“一种子对象”,但编译器不知道这样的指针或引用与其他指针或引用有何不同。也就是说,成员指针没有任何特殊的语义将其与其他指针区分开来。
16.2.10 自引用(Self-Reference)
状态更新函数 add_year(),add_month() 和 add_day() (§16.2.3) 被定义为不返回值。对于这样一组相关的更新函数,返回对更新对象的引用通常很有用,这样就可以链接操作。例如,为了添加一日,一月,一年到一个d上,我们想写成:
void f(Date& d)
{
// ...
d.add_day(1).add_month(1).add_year(1);
// ...
}
为此,必须声明每个函数以返回对日期的引用:
class Date {
// ...
Date& add_year(int n); // add n years
Date& add_month(int n); // add n months
Date& add_day(int n); // add n days
};
每个(非static)成员函数都知道它被哪个对象调用,并且可以显式地引用它。例如:
Date& Date::add_year(int n)
{
if (d==29 && m==2 && !leapyear(y+n)) { // beware of February 29
d = 1;
m = 3;
}
y += n;
return ∗this;
}
表达式 ∗this 指的是调用成员函数的对象。
在非static成员函数中,关键字 this 是指向调用该函数的对象的指针。在类 X 的非 const 成员函数中,this 的类型为 X∗。但是,this 被视为右值,因此无法获取 this 的地址或将其赋值给 this。在类 X 的 const 成员函数中,this 的类型为 const X∗,以防止修改对象本身(另请参见 §7.5)。
大多数 this 的使用都是隐式的。特别是,每个对类内非静态成员的引用都依赖于对 this 的隐式使用来获取相应对象的成员。例如,add_year 函数可以等效地(但很乏味地)定义为:
Date& Date::add_year(int n)
{
if (this−>d==29 && this−>m==2 && !leapyear(this−>y+n)) {
this−>d = 1;
this−>m = 3;
}
this−>y += n;
return ∗this;
}
this的一个常见的明确用途是链表操作。例如:
struct Link {
Link∗ pre;
Link∗ suc;
int data;
Link∗ insert(int x) // 在this之前插入x
{
return pre = new Link{pre ,this,x};
}
void remove() // 移除并销毁this
{
if (pre) pre−>suc = suc;
if (suc) suc−>pre = pre;
delete this;
}
// ...
};
从作为模板的派生类访问基类的成员时需要明确使用此项(§26.3.7)。
16.2.11 成员访问(Member-Access)
可以通过将 • (点)运算符应用于类 X 的对象或将 −> (箭头)运算符应用于指向类 X 的对象的指针来访问类 X 的成员。例如:
struct X {
void f();
int m;
};
void user(X x, X∗ px)
{
m = 1; // 错误 : 在作用域内没有m
x.m = 1; // OK
x−>m = 1; // 错: x 不是指针
px−>m = 1; // OK
px.m = 1; // 错: px 是一个指针
}
显然,这里有些冗余:编译器知道名称是指 X 还是 X∗,因此单个运算符就足够了。但是,程序员可能会感到困惑,因此从 C 语言诞生之日起,规则就是使用单独的运算符。
在类内部不需要运算符。例如:
void X::f()
{
m = 1; // OK: ‘‘this->m = 1;’’ (§16.2.10)
}
也就是说,非限定成员名称的作用就像是已以 this−> 作为前缀。请注意,成员函数可以在其完成声明之前引用成员的名称:
struct X {
int f() { return m; } // fine: return this X’s m
int m;
};
如果我们想引用一般成员,而不是引用特定对象的成员,则可以使用类名后跟 :: 来限定。例如:
struct S {
int m;
int f();
static int sm;
};
int X::f() { return m; } // X的 f
int X::sm {7}; // X的static成员sm (§16.2.12)
int (S::∗) pmf() {&S::f}; // X的成员f
(译注:注意,以上示例说明,从c++ 11开始,类名后跟 :: 可引用类的非static成员。)
最后一种构造(指向成员的指针)相当少见且深奥;请参阅 §20.6。我在这里提到它只是为了强调 :: 规则的普遍性。
16.2.12 类的[static]成员
Dates 的默认值的便利性是以一个重大的隐藏问题为代价的。我们的 Date 类依赖于全局变量 today。此 Date 类只能在 today 被定义并被每段代码正确使用的上下文中使用。这种限制导致类在其最初编写的上下文之外毫无用处。用户在尝试使用这种依赖于上下文的类时会遇到太多不愉快的意外,并且维护变得混乱。也许“只有一个小全局变量”并不太难以管理,但这种风格会导致代码除了原始程序员之外毫无用处。应该避免这种情况。
幸运的是,我们可以获得便利,而不必担心公共可访问的全局变量。属于类但不是该类对象的一部分的变量称为static成员。static成员只有一个副本,而不是像普通非static成员那样每个对象只有一个副本(§6.4.2)。类似地,需要访问类的成员但不需要为特定对象调用的函数称为static成员函数。
这里进行了重新设计,保留了 Date 默认构造函数值的语义,而没有因依赖全局而产生的问题:
class Date {
int d, m, y;
static Date default_date;
public:
Date(int dd =0, int mm =0, int yy =0);
// ...
// 设置set default_date 为Date(dd,mm,yy)
static void set_default(int dd, int mm, int yy);
};
我们现在可以定义 Date 构造函数来使用 default_date ,如下所示:
Date::Date(int dd, int mm, int yy)
{
d = dd ? dd : default_date .d;
m = mm ? mm : default_date .m;
y = yy ? yy : default_date .y;
// ... check that the Date is valid ...
}
使用 set_default(),我们可以在适当的时候更改默认日期。static成员可以像任何其他成员一样引用。此外,static成员可以在不提及对象的情况下引用。相反,它的名称由其类的名称限定。例如:
void f()
{
Date::set_default(4,5,1945); //调用Date和static 成员set_default()
}
如果使用,则必须在某处定义static成员(函数或数据成员)。关键字static 不会在static成员的定义中重复(译注:即定义中不需要使用static)。例如:
Date Date::default_date {16,12,1770}; // Date::default_date的定义
void Date::set_default(int d, int m, int y) //Date::set_default的定义
{
default_date = {d,m,y}; // 对default_date赋新值
}
现在,默认值是贝多芬的出生日期——直到有人另行决定。
请注意,Date{} 用作 Date::default_date 值的表示法。例如:
Date copy_of_default_date = Date{};
void f(Date);
void g()
{
f(Date{});
}
因此,我们不需要单独的函数来读取默认日期。此外,如果目标类型明确是日期,则简单的 {} 就足够了。例如:
void f1(Date);
void f2(Date);
void f2(int);
void g()
{
f1({}); // OK: 相当于 f1(Date{})
f2({}): // 错: 歧义: f2(int) 还是 f2(Date)?
f2(Date{}); // OK
}
在多线程代码中,static数据成员需要某种锁定或访问规则来避免竞争条件(§5.3.4,§41.2.4)。由于多线程现在非常普遍,不幸的是,static数据成员的使用在旧代码中非常流行。旧代码倾向于以暗示竞争条件的方式使用static成员。
16.2.13 类的成员类型
类型和类型别名可以是类的成员。例如:
template<typename T>
class Tree {
using value_type = T; // 别名成员
enum Policy { rb, splay, treeps }; // enum成员
class Node { //成员类
Node∗ right;
Node∗ left;
value_type value;
public:
void f(Tree∗);
};
Node∗ top;
public:
void g(const T&);
// ...
};
类成员(通常称为嵌套类)可以引用其封闭类的类型和static成员。只有当它被赋予一个封闭类的对象来引用时,它才能引用非static成员。为了避免陷入二叉树的复杂性,我使用了纯技术性的“f() 和 g()”风格的示例。
嵌套类可以访问其封闭类的成员,甚至可以访问私有成员(就像类的成员函数一样),但没有封闭类的当前对象的概念。例如:
template<typename T>
void Tree::Node::f(Tree∗ p)
{
top = right; // 错 : 未指定类型树的对象
p−>top = right; // OK
value_type v = left−>value; // OK: value_type 未与对象关联
}
类对其嵌套类的成员没有任何特殊访问权限。例如:
template<typename T>
void Tree::g(Tree::Node∗ p)
{
value_type val = right−>value; //错: 无类型为Tree::Node的对象
value_type v = p−>right−>value; //错: Node::right 是私有的
p−>f(this); //OK
}
成员类更多的是一种符号上的便利,而不是一个具有根本重要性的特征。另一方面,成员别名作为依赖关联类型的通用编程技术的基础非常重要(§28.2.4,§33.1.3)。当涉及到避免用枚举器的名称污染封闭作用域时(§8.4.1),成员枚举通常是枚举类的替代方案。
16.3 具体类(即简单用户定义类型,不旨在体现多态)
上一节在介绍定义类的基本语言特性的背景下,讨论了 Date 类设计的点点滴滴。在这里,我将重点反过来讨论一个简单而高效的 Date 类的设计,并展示语言特性如何支持这种设计。
小型、大量使用的抽象在许多应用程序中很常见。例如拉丁字符、中文字符、整数、浮点数、复数、点、指针、坐标、变换、(指针,偏移)对、日期、时间、范围、链接、关联、节点、(值,单位)对、磁盘位置、源代码位置、货币值、线、矩形、缩放的定点数、带分数的数字、字符串、向量和数组。每个应用程序都会使用其中的几个。通常,其中一些简单的具体类型被大量使用。典型的应用程序直接使用一些,并间接使用来自库的更多内容。
C++ 直接支持其中一些抽象作为内置类型。但是,由于数量太多,大多数抽象都不由语言直接支持,也无法直接支持。此外,通用编程语言的设计者无法预见每个应用程序的详细需求。因此,必须为用户提供定义小型具体类型的机制。此类类型称为具体类型或具体类,以区别于抽象类(§20.4)和类层次结构中的类(§20.3,§21.2)。
如果类的表示是其定义的一部分,则称该类为具体的(或具体类)。这将它与抽象类(§3.2.2,§20.4)区分开来,抽象类为各种实现提供了接口。有了具体类的表示,我们就可以:
• 将对象放置在栈、静态分配的内存和其他对象中
• 复制和移动对象(§3.3,§17.5)
• 直接引用命名对象(而不是通过指针和引用访问)
这使得具体类易于推理,并且编译器可以轻松为其生成最佳代码。因此,我们更喜欢将具体类用于小型、常用和性能关键的类型,例如复数(§5.6.2)、智能指针(§5.2.1)和容器(§4.4)。
C++ 早期的一个明确目标是很好地支持此类用户定义类型的定义和高效使用。它们是优雅编程的基础。通常,简单和普通的统计意义远大于复杂和精妙的统计意义。从这个角度来看,让我们构建一个更好的 Date 类:
namespace Chrono {
enum class Month { jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };
class Date {
public: // public interface:
class Bad_date { }; // exception class
explicit Date(int dd ={}, Month mm ={}, int yy ={}); // {} 指“选一个默认”
// nonmodifying functions for examining the Date:
int day() const;
Month month() const;
int year() const;
string string_rep() const; // string representation
void char_rep(char s[], in max) const; // C-style string representation
// (modifying) functions for changing the Date:
Date& add_year(int n); // add n years
Date& add_month(int n); // add n months
Date& add_day(int n); // add n days
private:
bool is_valid(); // check if this Date represents a date
int d, m, y; // representation
};
bool is_date(int d, Month m, int y); // true for valid date
bool is_leapyear(int y); // true if y is a leap year
bool operator==(const Date& a, const Date& b);
bool operator!=(const Date& a, const Date& b);
const Date& default_date(); // the default date
ostream& operator<<(ostream& os, const Date& d); // print d to os
istream& operator>>(istream& is, Date& d); // read Date from is into d
} // Chrono
对于用户定义类型来说,这组操作相当典型:
[1] 构造函数,指定如何初始化类型的对象/变量(§16.2.5)。
[2] 一组允许用户检查Date的函数。这些函数标记为 const 以表示它们不会修改调用它们的对象/变量的状态。
[3] 一组允许用户修改Dates的函数,而实际上不必知道表示的细节或摆弄语义的复杂性。
[4] 隐式定义的操作,允许自由复制Date (§16.2.2)。
[5] 一个类 Bad_date,用于将错误报告为异常。
[6] 一组有用的辅助函数。辅助函数不是成员,不能直接访问Date的表示,但它们通过使用命名空间 Chrono 被标识为相关。
我定义了一个 Month 类型来解决记住月份/日期顺序的问题,例如,以避免混淆 6 月 7 日是写为 {6,7}(美式)还是 {7,6}(欧式)。
我考虑引入单独的 Day 和 Year 类型,以应对 Date{1995,Month::jul,27} 和 Date{27,Month::jul,1995} 可能带来的混淆。然而,这些类型不如 Month 类型有用。几乎所有此类错误都会在运行时被捕获—— 27 年 7 月 26 日在我的工作中并不是一个常见的日期。处理 1800 年左右之前的历史日期是一个棘手的问题,最好留给历史专家。此外,无法将月份的日期与月份和年份分开进行正确检查。
为了让用户不必明确提及年份和月份(即使上下文暗示了它们),我添加了一种提供默认值的机制。请注意,对于Month,{} 给出的(默认)值 0 就像整数一样,即使它不是有效的Month(§8.4)。但是,在这种情况下,这正是我们想要的:一个否则无效的值来表示“选择默认值”。提供默认值(例如,Date 对象的默认值)是一个棘手的设计问题。对于某些类型,有一个常规默认值(例如,整数为 0);对于其他类型,没有默认值是有意义的;最后,有些类型(例如 Date)是否提供默认值的问题并不简单。在这种情况下,最好——至少最初——不要提供默认值。我为 Date 提供了一个默认值,主要是为了能够讨论如何做到这一点。
我从 §16.2.9 中删除了缓存技术,因为对于这种简单的类型来说,这是不必要的。如果需要,可以将其作为实现细节添加,而不会影响用户接口。
下面是一个关于如何使用Date的小例子:
void f(Date& d)
{
Date lvb_day {16,Month::dec,d.year()};
if (d.day()==29 && d.month()==Month::feb) {
// ...
}
if (midnight()) d.add_day(1);
cout << "day after:" << d+1 << '\n';
Date dd; // initialized to the default date
cin>>dd;
if (dd==d) cout << "Hurray!\n";
}
假设已为Dates声明了加法运算符 +。我在 §16.3.3 中这样做了。
请注意 Month 对 dec 和 feb 的明确限定。我使用了枚举类(enum class) (§8.4.1),以便能够使用Month的简称,同时确保它们的使用不会晦涩难懂或模棱两可。
为什么为像日期这样简单的东西定义一个特定的类型是值得的?毕竟,我们可以定义一个简单的数据结构:
struct Date {
int day, month, year;
};
然后,每个程序员都可以决定如何处理它。但是,如果我们这样做,每个用户要么必须直接操作Date的组件,要么提供单独的函数来执行此操作。实际上,日期的概念将分散在整个系统中,这将使其难以理解、记录或更改。不可避免地,将概念作为一个简单的结构会给该结构的每个用户带来额外的工作。
此外,尽管 Date 类型看似简单,但要想正确使用,还是需要花些心思。例如,增加 Date 必须处理闰年、月份长度不同的情况等等。此外,对于许多应用程序来说,日-月-年表示法相当糟糕。如果我们决定更改它,我们只需要修改一组指定的函数。例如,要将 Date 表示为 1970 年 1 月 1 日之前或之后的天数,我们只需要更改 Date 的成员函数。
为了简化,我决定消除更改默认日期的概念。这样做可以消除一些混淆的机会以及多线程程序中竞争条件的可能性(§5.3.1)。我认真考虑过完全消除默认日期的概念。这将迫使用户始终明确地初始化他们的Date。但是,这可能会不方便且令人惊讶,更重要的是,用于通用代码的通用接口需要默认构造(§17.3.3)。这意味着我作为 Date 的设计者,必须选择默认日期。我选择了 1970 年 1 月 1 日,因为这是 C 和 C++ 标准库时间例程的起点(§35.2,§43.6)。显然,消除 set_default_date() 会导致 Date 的通用性有所丧失。然而,设计(包括类设计)是关于做出决定,而不仅仅是决定推迟它们或为用户保留所有选项。
为了保留将来改进的机会,我将 default_date() 声明为辅助函数:
const Date& Chrono::default_date();
这并没有说明默认日期实际上是如何设置的。
16.3.1 具体类的成员函数
当然,必须在某处提供每个成员函数的实现。例如:
Date::Date(int dd, Month mm, int yy)
:d{dd}, m{mm}, y{yy}
{
if (y == 0) y = default_date().year();
if (m == Month{}) m = default_date().month();
if (d == 0) d = default_date().day();
if (!is_valid()) throw Bad_date();
}
构造函数检查提供的数据是否表示有效的Date。如果不是,例如,对于 {30,Month::feb,1994},它会抛出异常(§2.4.3.1,第 13 章),这表明出了问题。如果提供的数据可以接受,则显然初始化已完成。初始化是一个相对复杂的操作,因为它涉及数据验证。这是相当典型的。另一方面,一旦创建了 Date,就可以使用和复制它而无需进一步检查。换句话说,构造函数为类建立了不变量(在本例中,它表示有效日期)。其他成员函数可以依赖该不变量并且必须维护它。这种设计技术可以极大地简化代码(参见 §2.4.3.2,§13.4)。
我使用值 Month{}(它不代表月份,并且具有整数值 0)来表示“选择默认月份”。我本可以在 Month 中定义一个枚举器来专门表示这一点。但我认为最好使用明显异常的值来表示“选择默认月份”,而不是给人一年有 13 个月的感觉。请注意,可以使用 Month{}(表示 0),因为它在枚举 Month(§8.4)保证的范围内。
我使用成员初始化语法 (§17.4) 来初始化成员。之后,我检查是否为 0 并根据需要修改值。这显然无法在(希望很少见的)错误情况下提供最佳性能,但使用成员初始化器会使代码结构变得显而易见。这使得风格比其他方法更不容易出错,也更易于维护。如果我的目标是最佳性能,我会使用三个单独的构造函数,而不是一个带有默认参数的构造函数。
我考虑将验证函数 is_valid() 公开。但是,我发现生成的用户代码比依赖于捕获异常的代码更复杂,而且稳定性更差:
void fill(vector<Date>& aa)
{
while (cin) {
Date d;
try {
cin >> d;
}
catch (Date::Bad_date) {
// ... my error handling ...
continue;
}
aa.push_back(d); // see §4.4.2
}
}
但是,检查 {d,m,y} 组值是否为有效日期并不是依赖于Date表示的计算,因此我根据辅助函数实现了 is_valid():
bool Date::is_valid()
{
return is_date(d,m,y);
}
为什么同时使用 is_valid() 和 is_date()?在这个简单的例子中,我们只用一个就可以了,但我可以想象,在系统中,is_date()(如这里)检查 (d,m,y) 元组是否代表有效日期,而 is_valid() 会额外检查该日期是否可以合理表示。例如,is_valid() 可能会拒绝现代日历普遍使用之前的日期。
对于这种简单的具体类型来说,Date 的成员函数定义通常各不相同,有的简单,有的不太复杂。例如:
inline int Date::day() const
{
return d;
}
Date& Date::add_month(int n)
{
if (n==0) return ∗this;
if (n>0) {
int delta_y = n/12; // number of whole years
int mm = static_cast<int>(m)+n%12; // number of months ahead
if (12 < mm) { // note: dec is represented by 12
++delta_y;
mm −= 12;
}
// ... handle the cases where the month mm doesn’t have day d ...
y += delta_y;
m = static_cast<Month>(mm);
return ∗this;
}
// ... handle negative n ...
return ∗this;
}
我不会说 add_month() 的代码很美。事实上,如果我添加所有细节,它甚至可能接近相对简单的现实世界代码的复杂性。这指出了一个问题:添加月份在概念上很简单,那么为什么我们的代码变得复杂了?在这种情况下,原因是 d、m、y 表示对计算机来说并不像对我们那么方便。更好的表示(对于许多目的)将是自定义的“零日”(例如 1970 年 1 月 1 日)以来的天数。这将使Date计算变得简单,但代价是提供适合人类的输出的复杂性。
请注意,默认情况下提供赋值和复制初始化(§16.2.2)。此外,Date 不需要析构函数,因为 Date 不拥有任何资源,并且超出范围时不需要清理(§3.2.1.2)。
16.3.2 辅助函数(Helper Functions)
通常,类具有许多与其关联的函数,这些函数不需要在类本身中定义,因为它们不需要直接访问表示。例如:
int diff(Date a, Date b); // number of days in the range [a,b) or [b,a)
bool is_leapyear(int y);
bool is_date(int d, Month m, int y);
const Date& default_date();
Date next_weekday(Date d);
Date next_saturday(Date d);
在类本身中定义这样的函数会使类接口变得复杂,并增加在考虑改变表示时可能需要检查的函数数量。
这些函数如何与 Date 类“关联”?在早期的 C++ 中,与 C 一样,它们的声明只是放在与 Date 类声明相同的文件中。需要 Date 的用户可以通过包含定义接口的文件(§15.2.2)来使它们全部可用。例如:
#include "Date.h"
此外(或者作为替代方案),我们可以通过将类及其辅助函数封装在命名空间中来明确关联(§14.3.1):
namespace Chrono { // 处理时间的设施
class Date { /* ... */};
int diff(Date a, Date b);
bool is_leapyear(int y);
bool is_date(int d, Month m, int y);
const Date& default_date();
Date next_weekday(Date d);
Date next_saturday(Date d);
// ...
}
Chrono 命名空间自然也会包含相关类,例如 Time 和 Stopwatch,以及它们的辅助函数。使用命名空间来保存单个类通常过于繁琐,会导致不便。
当然,辅助函数必须在某处定义:
bool Chrono::is_date(int d, Month m, int y)
{
int ndays;
switch (m) {
case Month::feb:
ndays = 28+is_leapyear(y);
break;
case Month::apr: case Month::jun: case Month::sep: case Month::nov:
ndays = 30;
break;
case Month::jan: case Month::mar: case Month::may: case Month::jul:
case Month::aug: case Month::oct: case Month::dec:
ndays = 31;
break;
default:
return false;
}
return 1<=d && d<=ndays;
}
我故意在这里有点偏执。月份不应该超出 1 月到 12 月的范围,但有可能(有人可能在转换时马虎了),所以我检查了一下。
麻烦的default_date最终变成:
const Date& Chrono::default_date()
{
static Date d {1,Month::jan,1970};
return d;
}
16.3.3 重载运算符
添加函数以启用常规表示法通常很有用。例如,operator==() 定义相等运算符 ==,用于Date:
inline bool operator==(Date a, Date b) // equality
{
return a.day()==b.day() && a.month()==b.month() && a.year()==b.year();
}
其他明显的候选有:
bool operator!=(Date, Date); // 不等于
bool operator<(Date, Date); //小于
bool operator>(Date, Date); // 大于
// ...
Date& operator++(Date& d) { return d.add_day(1); } // 按天递增Date
Date& operator−−(Date& d) { return d.add_day(−1); } // 按天递降Date
Date& operator+=(Date& d, int n) { return d.add_day(n); } // add n days
Date& operator−=(Date& d, int n) { return d.add_day(−n); } // subtract n days
Date operator+(Date d, int n) { return d+=n; } // 添加n天
Date operator−(Date d, int n) { return d+=n; } // 减去n天
ostream& operator<<(ostream&, Date d); // output d
istream& operator>>(istream&, Date& d); // read into d
这些运算符在 Chrono 中与 Date 一起定义,以避免重载问题并从基于参数的查找中受益(§14.2.4)。
对于 Date,这些运算符可以看作仅仅是方便而已。然而,对于许多类型(例如复数(§18.3)、向量(§4.4.1)和函数类对象(§3.4.3,§19.2.2)),传统运算符的使用已深深扎根于人们的心中,因此它们的定义几乎是强制性的。第 18 章讨论了运算符重载。
对于 Date,我曾试图提供 += 和 −= 作为成员函数,而不是 add_day()。如果我这样做了,我就会遵循一个常见的习惯用法(§3.2.1.1)。
请注意,默认情况下提供赋值和复制初始化(§16.3,§17.3.3)。
16.3.4 具体类的意义
我将简单的用户定义类型(例如 Date)称为具体类型,以区别于抽象类(§3.2.2)和类层次结构(§20.4),同时也强调它们与内置类型(例如 int 和 char)的相似性。具体类的使用方式与内置类型相同。具体类型也被称为值类型,其用途为面向值编程。它们的使用模型和设计背后的“哲学”与通常所说的面向对象编程(§3.2.4,第 21 章)截然不同。
具体类型的目的在于高效地完成一件相对简单的事情。通常,它的目的不是为用户提供修改具体类型行为的便利。特别是,具体类型不旨在显示运行时多态行为(参见 §3.2.3,§20.3.2)。
如果您不喜欢某个具体类型的某些细节,您可以构建一个具有所需行为的新类型。如果您想“重用”某个具体类型,您可以在新类型的实现中使用它,就像使用 int 一样。例如:
class Date_and_time {
private:
Date d;
Time t;
public:
Date_and_time(Date d, Time t);
Date_and_time(int d, Date::Month m, int y, Time t);
// ...
};
或者,第 20 章中讨论的派生类机制可用于通过描述所需的差异来从具体类定义新类型。从vector定义 Vec(§4.4.1.2)就是一个例子。但是,从具体类派生应该小心谨慎,而且很少发生,因为缺乏虚函数和运行时类型信息(§17.5.1.4,第 22 章)。
使用一个相当好的编译器,诸如 Date 之类的具体类不会在时间或空间上产生任何隐藏的开销。特别是,访问具体类的对象不需要通过指针进行间接访问,并且具体类的对象中不会存储任何“内部”数据。具体类型的大小在编译时已知,因此可以在运行时栈上分配对象(即,无需自由存储操作)。对象的布局在编译时已知,因此可以轻松实现操作的内联。同样,与其他语言(如 C 和 Fortran)的布局兼容性也无需特别努力。
一组好的此类类型可以为应用程序提供基础。特别是,它们可以用于使接口更加具体且更不容易出错。例如:
Month do_something(Date d);
与以下情况相比,这不太可能被误解或误用:
int do_something(int d);
如果每个程序员都编写代码来直接操作以内置类型的简单集合表示的“简单且常用”数据结构,则缺乏具体类型会导致程序晦涩难懂,浪费时间。或者,如果应用程序中缺少合适的“小型高效类型”,则在使用过于通用且昂贵的类时,会导致严重的运行时和空间效率低下。
16.4 建议
[1] 将概念表示为类;§16.1。
[2] 将类的接口与其实现分开;§16.1。
[3] 仅当它实际上只是数据并且不变量对数据成员没有意义时才使用公共数据(struct);§16.2.4。
[4] 定义构造函数来处理对象的初始化;§16.2.5。
[5] 默认情况下,将单参数构造函数声明为explicit;§16.2.6。
[6] 将不修改其对象状态的成员函数声明为 const;§16.2.9。
[7] 具体类型是最简单的类。在适用的情况下,优先使用具体类型,而不是更复杂的类和普通数据结构;§16.3。
[8] 仅当函数需要直接访问类的表示时才将其设为成员;§16.3.2。
[9] 使用命名空间明确类与其辅助函数之间的关联;§16.3.2。
[10] 将不修改其对象值的成员函数设为 const 成员函数;§16.2.9.1。
[11] 将需要访问类表示但不需要为特定对象调用的函数设为static成员函数;§16.2.12。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup