C++ 的主要设计目标之一是让程序员能够定义外观和行为与内置类型几乎相同的自定义类型。类和重载操作符的结合赋予了你这种能力。这个探索更深入地研究了类型系统,以及你的类如何更好地适应 C++ 世界。
假设您正在编写一个函数,根据以厘米为单位的整数身高和以千克为单位的整数体重来计算伪代谢指数(身体质量指数)。编写这样的函数没有任何困难(可以从探索 29 和 35 中复制)。为了更加清晰,您决定为height
和weight
添加typedef
s,这允许程序员定义变量来存储和操作这些值,对人类读者来说更加清晰。清单 40-1 显示了compute_bmi()
函数和相关typedef
的简单用法
import <iostream>;
using height = int;
using weight = int;
using bmi = int;
bmi compute_bmi(height h, weight w)
{
return w * 10000 / (h * h);
}
int main()
{
std::cout << "Height in centimeters: ";
height h{};
std::cin >> h;
std::cout << "Weight in kilograms: ";
weight w{};
std::cin >> w;
std::cout << "Bogus Metabolic Index = " << compute_bmi(w, h) << '\n';
}
Listing 40-1.Computing BMI
测试程序。怎么了?
如果您还没有发现它,请仔细看看对main()
中最后一行代码compute_bmi()
的调用。将自变量与函数定义中的参数进行比较。现在你明白问题了吗?
尽管height
和weight using
声明提供了额外的清晰性,我仍然犯了一个根本性的错误,颠倒了论点的顺序。在这种情况下,错误很容易被发现,因为程序很小。此外,程序的输出是如此明显地错误,以至于测试很快就揭示了问题。不过,不要太放松;不是所有的错误都这么明显。
这里的问题是,using
声明没有定义新的类型,而是为现有类型创建了一个别名。原始类型及其别名是完全可以互换的。因此,height
与int
相同,与weight
相同。因为程序员能够混淆height
和weight
,所以using
声明实际上没有多大帮助。
更有用的是创建名为height
和weight
的不同类型。作为不同的类型,您不能混淆它们,并且您可以完全控制您允许的操作。例如,将两个weight
相除会产生一个简单的、无单位的int
。将一个height
加到一个weight
上会导致编译器发出一条错误消息。清单 40-2 显示了施加这些限制的简单的height
和weight
类。
import <iostream>;
/// Height in centimeters
class height
{
public:
height(int h) : value_{h} {}
int value() const { return value_; }
private:
int value_;
};
/// Weight in kilograms
class weight
{
public:
weight(int w) : value_{w} {}
int value() const { return value_; }
private:
int value_;
};
std::istream& operator>>(std::istream& stream, height& ht)
{
int tmp;
if (stream >> tmp)
ht = height{tmp};
return stream;
}
std::istream& operator>>(std::istream& stream, weight& wt)
{
int tmp;
if (stream >> tmp)
wt = weight{tmp};
return stream;
}
/// Body-mass index
class bmi
{
public:
bmi() : value_{0} {}
bmi(height h, weight w)
: value_{(w.value() * 10000) / (h.value() * h.value())}
{}
int value() const { return value_; }
private:
int value_;
};
std::ostream& operator<<(std::ostream& out, bmi x)
{
return out << x.value();
}
int main()
{
std::cout << "Height in centimeters: ";
height h{0};
std::cin >> h;
std::cout << "Weight in kilograms: ";
weight w{0};
std::cin >> w;
std::cout << "Bogus metabolic index = " << bmi(h, w) << '\n';
}
Listing 40-2.Defining Classes for height and weight
新的类防止了错误,比如清单 40-1 中的错误,但代价是更多的代码。例如,您必须编写合适的 I/O 操作符。您还必须决定要实现哪些算术运算符。在这个简单的应用程序中,我们只实现了这个程序所需的操作符。为了在一个更大的程序中表示一个逻辑权重,您可能需要实现可以对一个权重执行的所有可能的操作,比如将两个权重相加、相减、相除等等。不要忘记比较运算符。这些函数大部分写起来都很琐碎,但是你不能忽视它们。然而,在许多应用中,通过消除潜在的误差源,这项工作将会得到很多倍的回报。
我并不是建议您抛弃不加修饰的整数和其他内置类型,用包装类来代替它们。事实上,我同意你的观点(不要问我怎么知道你在想什么),身体质量指数的例子是相当人为的。如果我正在编写一个真正的、诚实的程序来计算和管理 BMI,我会使用普通的int
变量,并依靠仔细的编码和校对来防止和检测错误。我使用包装类,比如height
和weight
,当它们添加一些主值时。一个高度和重量占主导地位的大程序会给错误提供很多机会。在这种情况下,我想使用包装类。我还可以给类添加一些错误检查,对它们可以表示的值域施加约束,或者帮助自己完成程序员的工作。尽管如此,最好从简单开始,慢慢地、小心地增加复杂性。下一节将更详细地解释要创建一个有用且有意义的自定义类,您必须实现什么行为。
height
和weight
类型是值类型的示例,即表现为普通值的类型。将它们与 I/O 流类型进行对比,后者的行为非常不同。例如,您不能复制或分配流;您必须通过引用函数来传递它们。也不能比较流或对它们执行算术运算。按照设计,值类型的行为类似于内置类型,比如int
和float
。值类型的一个重要特点是你可以将它们存储在容器中,比如vector
和map
。本节解释值类型的一般要求。
基本的指导方针是确保你的类型的行为“像一个int
”当涉及到复制、比较和执行算术时,通过使您的自定义类型在外观、行为和工作上尽可能像内置类型来避免意外。
复制一个int
会产生一个新的int
,这个新的int
与原始的int
无法区分。您的自定义类型应该以同样的方式运行。
考虑一下string
的例子。string
的许多实现都是可能的。其中一些使用写入时复制来优化频繁的复制和分配。在写入时复制实现中,实际的字符串内容与string
对象是分开的。对象的副本不会复制内容,除非需要一个副本,这发生在必须修改字符串内容的时候。字符串的许多用法都是只读的,所以写时复制避免了不必要的内容复制,即使string
对象本身被频繁复制。
其他实现通过使用string
对象存储内容来优化小字符串,但是单独存储大字符串。复制小字符串速度很快,但复制大字符串速度较慢。大多数程序只使用小字符串。尽管在实现上有这些差异,但是当您复制一个string
(比如通过值将一个string
传递给一个函数)时,副本和原始的是无法区分的,就像一个int
。
通常情况下,编译器的自动复制构造器做你想做的,你不用写任何代码。尽管如此,您必须考虑复制,并确保编译器的自动(也称为隐式)复制构造器完全符合您的要求。
分配对象类似于复制对象。赋值后,目标和源必须包含相同的值。赋值和复制的主要区别在于,复制是从一张白纸开始的:一个正在构建的对象。赋值从一个现有对象开始,在赋值新值之前,您可能必须清除旧值。简单类型如height
没有什么需要清理的,但是在本书的后面,你将学习如何实现更复杂的类型,如string
,这需要仔细的清理。
大多数简单的类型使用编译器的隐式赋值运算符就可以很好地工作,并且您不必编写自己的类型。尽管如此,您必须考虑这种可能性,并确保隐式赋值运算符正是您想要的。
有时候,你不想做一个精确的拷贝。我知道我写了赋值应该产生一个精确的副本,但是你可以通过让赋值将一个值从源移动到目标来打破这个规则。结果使源处于未知状态(通常为空),目标获得源的原始值。
通过调用std::move
(在<utility>
中声明)强制移动赋值:
std::string source{"string"}, target{};
target = std::move(source);
赋值后,source
处于未知但有效的状态。通常,它将为空,但您不能编写假定它为空的代码。实际上,source
的字符串内容被移到了target
中,而没有复制任何字符串内容。移动速度很快,并且与容器中存储的数据量无关。
您也可以移动初始化器中的对象,如下所示:
std::string source{"string"};
std::string target{std::move(source)};
移动适用于字符串和大多数容器,包括std::vector
。考虑清单 40-3 中的程序。
import <iostream>;
import <utility>;
import <vector>;
void print(std::vector<int> const& vector)
{
std::cout << "{ ";
for (int i : vector)
std::cout << i << ' ';
std::cout << "}\n";
}
int main()
{
std::vector<int> source{1, 2, 3 };
print(source);
std::vector<int> copy{source};
print(copy);
std::vector<int> move{std::move(source)};
print(move);
print(source);
}
Listing 40-3.Copying vs. Moving
预测清单中程序的输出 40-3 。
当我运行这个程序时,我得到了这个:
{ 1 2 3 }
{ 1 2 3 }
{ 1 2 3 }
{ }
前三行打印{ 1 2 3 }
,不出所料。但是最后一行很有趣,因为source
被移到了move
。移动对象后,唯一允许做的事情是赋值或将对象重置为已知状态,因此不能保证打印出来的结果与您预期的一样,并且您的 C++ 库可能会做一些与我使用的不同的事情。
编写移动构造器是高级的,将不得不等到本书的后面,但是您可以通过调用std::move()
来利用标准库中的移动构造器和移动赋值操作符。
我用一种需要有意义的比较的方式来定义复制和赋值。如果你不能确定两个对象是否相等,你就不能验证你是否正确地复制或赋值了它们。C++ 有几种方法来检查两个对象是否相同:
-
第一种也是最明显的方法是用
==
操作符比较对象。值类型应重载此运算符。确保操作符是可传递的——也就是说,如果a == b
和b == c
,那么a == c
。确保算子是可换的,即如果a == b
,那么b == a
。最后,算子要反身:a == a
。 -
像
find
这样的标准算法通过两种方法中的一种来比较条目:用operator==
或者用调用者提供的谓词。有时,您可能想用一个定制的谓词来比较对象,例如,person
类可能有operator==
来比较每个数据成员(名字、地址等等)。),但是您想通过只检查姓氏来搜索一个包含person
对象的容器,这可以通过编写自己的比较函数来实现。自定义谓词必须遵守与==
操作符相同的传递性和自反性限制。如果将谓词用于特定的算法,该算法会以特定的方式调用谓词,因此您知道参数的顺序。你不必让你的谓词可交换,在某些情况下,你不会想这样做。 -
像
map
这样的容器按照排序的顺序存储它们的元素。一些标准算法,比如binary_search
,要求它们的输入范围是有序的。有序容器和算法使用相同的约定。默认情况下,它们使用<
操作符,但是您也可以提供自己的比较谓词。这些容器和算法从不使用==
操作符来确定两个对象是否相同。相反,它们检查等价性——也就是说,如果a < b
为假而b < a
为假,那么a
等价于b
。如果你的值类型可以排序,你应该重载
<
操作符。确保操作符是可传递的(如果a < b
和b < c
,那么a < c
)。还有,排序必须严格,也就是说,a < a
永远是假的。 -
检查等价性的容器和算法也采用一个可选的定制谓词来代替
<
操作符。定制谓词必须遵守与<
操作符相同的传递性和严格性限制。
并非所有类型都可以通过小于关系进行比较。如果你的类型不能被排序,不要实现<
操作符,但是你也必须明白你不能在map
中存储该类型的对象或者使用任何二分搜索法算法。有时,你可能想要强加一个人为的命令,仅仅是为了允许这些用途。例如,color
类型可以表示诸如red
、green
或yellow
的颜色。尽管red
或green
本身并没有将一个定义为“小于”另一个,但是您可能想要定义一个任意的顺序,这样您就可以将这些值用作map
中的键。一个直接的建议是编写一个比较函数,使用<
操作符将颜色作为整数进行比较。
另一方面,如果你有一个应该比较的值(比如rational
,你应该实现operator==
和operator<
。然后,您可以根据这两个运算符实现所有其他比较运算符。(参见探索 33 中rational
类如何做到这一点的例子。)
如果你必须在一个map
中存储无序对象,你可以使用std::unordered_map
。它的工作方式几乎与std::map
完全相同,但是它将值存储在哈希表中,而不是二叉树中。确保自定义类型可以存储在std::unordered_map
中是更高级的,直到很久以后才会涉及到。
实现一个颜色类,它将一种颜色描述为三种成分:红色、绿色和蓝色,它们是 0 到 255 之间的整数。定义一个比较函数order_color
,允许将颜色存储为map
键。**为了额外加分,设计一个合适的 I/O 格式并让 I/O 操作符过载。**先不要担心错误处理——例如,如果用户试图将红色设置为 1000,蓝色设置为 2000,绿色设置为 3000 会怎么样。你很快就会明白的。
将你的解决方案与我的进行比较,我的解决方案在清单 40-4 中给出。
import <iomanip>;
import <iostream>;
import <sstream>;
class color
{
public:
color() : color{0, 0, 0} {}
color(color const&) = default;
color(int r, int g, int b) : red_{r}, green_{g}, blue_{b} {}
int red() const { return red_; }
int green() const { return green_; }
int blue() const { return blue_; }
/// Because red(), green(), and blue() are supposed to be in the range [0,255],
/// it should be possible to add them together in a single long integer.
/// TODO: handle out of range
long int combined() const { return ((red() * 256L + green()) * 256) + blue(); }
private:
int red_, green_, blue_;
};
inline bool operator==(color const& a, color const& b)
{
return a.combined() == b.combined();
}
inline bool operator!=(color const& a, color const& b)
{
return not (a == b);
}
inline bool order_color(color const& a, color const& b)
{
return a.combined() < b.combined();
}
/// Write a color in HTML format: #RRGGBB.
std::ostream& operator<<(std::ostream& out, color const& c)
{
std::ostringstream tmp{};
// The hex manipulator tells a stream to write or read in hexadecimal (base 16).
// Use a temporary stream in case the out stream has its own formatting,
// such as width, adjustment.
tmp << '#' << std::hex << std::setw(6) << std::setfill('0') << c.combined();
out << tmp.str();
return out;
}
class ioflags
{
public:
/// Save the formatting flags from @p stream.
ioflags(std::basic_ios<char>& stream) : stream_{stream}, flags_{stream.flags()} {}
ioflags(ioflags const&) = delete;
/// Restore the formatting flags.
~ioflags() { stream_.flags(flags_); }
private:
std::basic_ios<char>& stream_;
std::ios_base::fmtflags flags_;
};
std::istream& operator>>(std::istream& in, color& c)
{
ioflags flags{in};
char hash{};
if (not (in >> hash))
return in;
if (hash != '#')
{
// malformed color: no leading # character
in.unget(); // return the character to the input stream
in.setstate(in.failbit); // set the failure state
return in;
}
// Read the color number, which is hexadecimal: RRGGBB.
int combined{};
in >> std::hex >> std::noskipws;
if (not (in >> combined))
return in;
// Extract the R, G, and B bytes.
int red, green, blue;
blue = combined % 256;
combined = combined / 256;
green = combined % 256;
combined = combined / 256;
red = combined % 256;
// Assign to c only after successfully reading all the color components.
c = color{red, green, blue};
return in;
}
int main()
{
color c;
while (std::cin >> c)
{
if (c == color{})
std::cout << "black\n";
else
std::cout << c << '\n';
}
}
Listing 40-4.The color Class
清单 40-3 用ioflags
职业引入了一个新的技巧。下一节将解释所有内容。
一个被称为资源获取的编程习惯用法是初始化(RAII ),它利用了构造器、析构函数和函数返回时对象的自动销毁。简而言之,RAII 习惯用法意味着一个构造器获取一个资源:它打开一个文件,连接到一个网络,或者甚至只是从一个 I/O 流中复制一些标志。采集是对象初始化的一部分。析构函数释放资源:关闭文件,断开网络连接,或者恢复 I/O 流中任何修改过的标志。
要使用 RAII 类,您只需定义该类型的对象。仅此而已。编译器会处理剩下的事情。RAII 类的构造器接受获取其资源所需的任何参数。当周围函数返回时,RAII 对象被自动销毁,从而释放资源。就这么简单。
你甚至不用等到函数返回。在复合语句中定义一个 RAII 对象,当语句结束且控制离开复合语句时,该对象被销毁。
清单 40-4 中的ioflags
类是使用 RAII 的一个例子。它向你扔出一些新的物品;让我们一次解决一个问题:
-
std::basic_ios<char>
类是所有 I/O 流类的基类,比如istream
和ostream
。因此,ioflags
对输入和输出流的作用是一样的。 -
std::ios_base::fmtflags
类型是所有格式化标志的类型。 -
没有参数的
flags()
成员函数返回所有当前的格式化标志。 -
带有一个参数的
flags()
成员函数将所有标志设置为它的参数。
使用ioflags
的方法就是在函数或复合语句中定义一个ioflags
类型的变量,将一个流对象作为唯一的参数传递给构造器。该函数可以改变流的任何标志。在这种情况下,输入操作符用std::hex
操纵器将输入基数(或基数)设置为十六进制。输入基数与格式化标志一起存储。运算符也关闭skipws
标志。默认情况下,此标志是启用的,它指示标准输入操作符跳过初始空白。通过关闭这个标志,输入操作符不允许在英镑符号(#
)和颜色值之间有任何空白。
当输入函数返回时,ioflags
对象被销毁,它的析构函数恢复原来的格式化标志。如果没有 RAII 的魔力,operator>>
函数将不得不在所有四个返回点手动恢复标志,这是一项繁重的工作,并且容易出错。
复制一个ioflags
对象毫无意义。如果复制它,哪个对象将负责恢复标志?因此,该类删除了复制构造器。如果您不小心编写了复制ioflags
对象的代码,编译器就会抱怨。
RAII 是 C++ 中常见的编程习惯用法。你对 C++ 了解得越多,你就会越欣赏它的美丽和简单。
如您所见,我们的示例变得越来越复杂,对我来说,在一个代码清单中包含所有的示例变得越来越困难。您的下一个任务是了解如何将您的代码分成多个文件,这将使我的工作和您的工作更加容易。这项新任务的第一步是仔细研究声明、定义以及它们之间的区别。