三十五、关于成员函数的更多信息
成员函数和构造器甚至比你目前所学的更有趣。这一探索继续揭开它们的神秘面纱。
重访项目 1
你觉得项目 1(探索 29 )最让你沮丧的是什么?如果您和我一样(尽管为了您自己,我希望您不是),您可能会对必须定义几个单独的向量来存储一组记录感到失望。然而,在不了解类的情况下,这是唯一可行的方法。既然已经向您介绍了类,您就可以修复程序了。**编写一个类定义来存储一条记录。**详细信息请参考探索 29 。简而言之,每条记录都记录了以厘米为单位的整数身高、以千克为单位的整数体重、计算出的身体质量指数(可以四舍五入为整数)、这个人的性别(字母'M'
或'F'
)以及这个人的名字(一个string
)。
接下来,编写一个 read 成员函数,从一个 istream
中读取一条记录。它有两个参数:一个istream
和一个整数。通过写信给std::cout
来提示用户每条信息。integer 参数是记录号,可以在提示中使用。编写一个打印成员函数,打印一条记录;它采用一个ostream
和一个整数阈值作为参数。
最后,修改程序以利用你写的新类。将你的解决方案与我的进行比较,如清单 35-1 所示。
#include <cstdlib>
import <algorithm>;
import <iomanip>;
import <iostream>;
import <limits>;
import <locale>;
import <ranges>;
import <string>;
import <vector>;
/// Compute body-mass index from height in centimeters and weight in kilograms.
int compute_bmi(int height, int weight)
{
return static_cast<int>(weight * 10000 / (height * height) + 0.5);
}
/// Skip the rest of the input line.
void skip_line(std::istream& in)
{
in.ignore(std::numeric_limits<int>::max(), '\n');
}
/// Represent one person's record, storing the person's name, height, weight,
/// sex, and body-mass index (BMI), which is computed from the height
and weight.
struct record
{
record() : height_{0}, weight_{0}, bmi_{0}, sex_{'?'}, name_{}
{}
/// Get this record, overwriting the data members.
/// Error-checking omitted for brevity.
/// @return true for success or false for eof or input failure
bool read(std::istream& in, int num)
{
std::cout << "Name " << num << ": ";
std::string name{};
if (not std::getline(in, name))
return false;
std::cout << "Height (cm): ";
int height{};
if (not (in >> height))
return false;
skip_line(in);
std::cout << "Weight (kg): ";
int weight{};
if (not (in >> weight))
return false;
skip_line(in);
std::cout << "Sex (M or F): ";
char sex{};
if (not (in >> sex))
return false;
skip_line(in);
sex = std::toupper(sex, std::locale());
// Store information into data members only after reading
// everything successfully.
name_ = name;
height_ = height;
weight_ = weight;
sex_ = sex;
bmi_ = compute_bmi(height_, weight_);
return true;
}
/// Print this record to @p out.
void print(std::ostream& out, int threshold)
{
out << std::setw(6) << height_
<< std::setw(7) << weight_
<< std::setw(3) << sex_
<< std::setw(6) << bmi_;
if (bmi_ >= threshold)
out << '*';
else
out << ' ';
out << ' ' << name_ << '\n';
}
int height_; ///< height in centimeters
int weight_; ///< weight in kilograms
int bmi_; ///< Body-mass index
char sex_; ///< 'M' for male or 'F' for female
std::string name_; ///< Person’s name
};
/** Print a table
.
* Print a table of height, weight, sex, BMI, and name.
* Print only records for which sex matches @p sex.
* At the end of each table, print the mean and median BMI.
*/
void print_table(char sex, std::vector<record>& records, int threshold)
{
std::cout << "Ht(cm) Wt(kg) Sex BMI Name\n";
float bmi_sum{};
long int bmi_count{};
std::vector<int> tmpbmis{}; // store only the BMIs that are printed
// in order to compute the median
for (auto rec : records)
{
if (rec.sex_ == sex)
{
bmi_sum = bmi_sum + rec.bmi_;
++bmi_count;
tmpbmis.push_back(rec.bmi_);
rec.print(std::cout, threshold);
}
}
// If the vectors are not empty, print basic statistics.
if (bmi_count != 0)
{
std::cout << "Mean BMI = "
<< std::setprecision(1) << std::fixed << bmi_sum / bmi_count
<< '\n';
// Median BMI is trickier. The easy way is to sort the
// vector and pick out the middle item or items.
std::ranges::sort(tmpbmis);
std::cout << "Median BMI = ";
// Index of median item.
std::size_t i{tmpbmis.size() / 2};
if (tmpbmis.size() % 2 == 0)
std::cout << (tmpbmis.at(i) + tmpbmis.at(i-1)) / 2.0 << '\n';
else
std::cout << tmpbmis.at(i) << '\n';
}
}
/** Main program to compute BMI. */
int main()
{
std::locale::global(std::locale{""});
std::cout.imbue(std::locale{});
std::cin.imbue(std::locale{});
std::vector<record> records{};
int threshold{};
std::cout << "Enter threshold BMI: ";
if (not (std::cin >> threshold))
return EXIT_FAILURE;
skip_line(std::cin);
std::cout << "Enter name, height (in cm),"
" and weight (in kg) for each person:\n";
record rec{};
while (rec.read(std::cin, records.size()+1))
{
records.emplace_back(rec);
std::cout << "BMI = " << rec.bmi_ << '\n';
}
// Print the data.
std::cout << "\n\nMale data\n";
print_table('M', records, threshold);
std::cout << "\nFemale data\n";
print_table('F', records, threshold);
}
Listing 35-1.New BMI Program
那是很难接受的,所以慢慢来。我会在这里等你做完。当面对一个你必须阅读和理解的新课堂时,从阅读评论开始(如果有的话)。一种方法是先略读类以识别成员(函数和数据),然后重读类以深入理解成员函数。一次处理一个成员函数。
你可能会问自己为什么我没有重载>>
和<<
操作符来读写record
对象。该计划的要求比这些运营商提供的稍微复杂一些。例如,读取一个record
还涉及到打印提示,每个提示都包含一个序号,因此用户知道该键入哪条记录。根据阈值的不同,某些记录的打印方式会有所不同。>>
操作者没有方便的方法来指定阈值。重载 I/O 操作符对于简单类型来说很好,但是通常不适合更复杂的情况。
常量成员函数
仔细看看print_table
函数。注意到它的参数有什么不寻常或可疑的地方吗?records
参数是通过引用传递的,但是函数从不修改它,所以你真的应该把它作为引用传递给const
。去改变吧。会发生什么?
您应该会看到编译器出错。当records
为const
时,auto rec : records
类型也必须声明rec
为const
。因此,当print_table
调用rec.print()
时,在print()
函数内部,这指的是一个const record
对象。虽然print()
不修改record
对象,但是它可以,而且编译器必须考虑到这种可能性。你必须告诉编译器print()
是安全的,不修改任何数据成员。通过在print()
函数签名和函数体之间添加一个const
修饰符来实现。清单 35-2 显示了print
成员函数的新定义。
/// Print this record to @p out.
void print(std::ostream& out, int threshold)
const
{
out << std::setw(6) << height_
<< std::setw(7) << weight_
<< std::setw(3) << sex_
<< std::setw(6) << bmi_;
if (bmi_ >= threshold)
out << '*';
else
out << ' ';
out << ' ' << name_ << '\n';
}
Listing 35-2.Adding the const Modifier to print
一般来说,对任何不改变任何数据成员的成员函数使用const
修饰符。这确保了当您有一个const
对象时,您可以调用成员函数。复制清单 34-4 中的代码,并对其进行修改,在适当的地方添加 const
修饰符。将您的结果与清单 35-3 中的结果进行比较。
#include <cmath> // for sqrt and atan2
struct point
{
/// Distance to the origin.
double distance()
const
{
return std::sqrt(x*x + y*y);
}
/// Angle relative to x-axis.
double angle()
const
{
return std::atan2(y, x);
}
/// Add an offset to x and y.
void offset(double off)
{
offset(off, off);
}
/// Add an offset to x and an offset to y
void offset(double xoff, double yoff)
{
x = x + xoff;
y = y + yoff;
}
/// Scale x and y.
void scale(double mult)
{
this->scale(mult, mult);
}
/// Scale x and y.
void scale(double xmult, double ymult)
{
this->x = this->x * xmult;
this->y = this->y * ymult;
}
double x;
double y;
};
Listing 35-3.const Member Functions for Class point
scale
和offset
函数修改数据成员,所以不能是const
。angle
和distance
成员函数不修改任何成员,所以它们是const
。
给定一个point
变量,你可以调用任何成员函数。然而,如果对象是const
,你只能调用const
成员函数。最常见的情况是当您发现自己在另一个函数中有一个const
对象,并且该对象是通过引用const
传递的,如清单 35-4 所示。
#include <cmath>
import <iostream>;
// Use the same point definition as Listing 35-3
... omitted for brevity ...
void print_polar(point const& pt)
{
std::cout << "{ r=" << pt.distance() << ", angle=" << pt.angle() << " }\n";
}
void print_cartesian(point const& pt)
{
std::cout << "{ x=" << pt.x << ", y=" << pt.y << " }\n";
}
int main()
{
point p1{}, p2{};
double const pi{3.141592653589792};
p1.x = std::cos(pi / 3);
p1.y = std::sin(pi / 3);
print_polar(p1);
print_cartesian(p1);
p2 = p1;
p2.scale(4.0);
print_polar(p2);
print_cartesian(p2);
p2.offset(0.0, -2.0);
print_polar(p2);
print_cartesian(p2);
}
Listing 35-4.Calling const and Non-const Member Functions
成员函数的另一个常见用途是限制对数据成员的访问。想象一下,如果一个使用身体质量指数record
类型的程序不小心修改了bmi_
成员,会发生什么。更好的设计是让您调用一个bmi()
函数来获取身体质量指数,但隐藏bmi_
数据成员,以防止意外修改。您可以预防此类事故,接下来的探索将向您展示如何预防。
三十六、访问级别
每个人都有秘密,有些人比其他人多。班级也有秘密。例如,在这本书里,你使用了std::string
类,却不知道该类内部发生了什么。实现细节是秘密——不是严密保护的秘密,但仍然是秘密。您不能直接检查或修改任何string
的数据成员。相反,它提供了相当多的组成其公共接口的成员函数。您可以自由使用任何公开可用的成员函数,但只能使用公开可用的成员函数。这个探索解释了如何在你的类中做同样的事情。
公共与私有
一个类的作者决定哪些成员是秘密的(仅供该类自己的成员函数使用),哪些成员可供程序中的任何其他代码自由使用。秘密成员称为私人,任何人都可以使用的成员称为公共。隐私设置被称为访问级别。(当你阅读 C++ 代码时,你可能会看到另一个访问级别,protected
。我稍后会谈到这一点。两个访问级别就足够了。)
要指定访问级别,请使用private
关键字或public
关键字,后跟一个冒号。类定义中的所有后续成员都具有该可访问性级别,直到您用新的访问级别关键字对其进行更改。清单 36-1 显示了带有访问级别说明符的point
类。
struct point
{
public:
point() : point{0.0, 0.0} {}
point(double x, double y) : x_{x}, y_{y} {}
point(point const&) = default;
double x() const { return x_; }
double y() const { return y_; }
double angle() const { return std::atan2(y(), x()); }
double distance() const { return std::sqrt(x()*x() + y()*y()); }
void move_cartesian(double x, double y)
{
x_ = x;
y_ = y;
}
void move_polar(double r, double angle)
{
move_cartesian(r * std::cos(angle), r * std::sin(angle));
}
void scale_cartesian(double s) { scale_cartesian(s, s); }
void scale_cartesian(double xs, double ys)
{
move_cartesian(x() * xs, y() * ys);
}
void scale_polar(double r) { move_polar(distance() * r, angle()); }
void rotate(double a) { move_polar(distance(), angle() + a); }
void offset(double o) { offset(o, o); }
void offset(double xo, double yo) { move_cartesian(x() + xo, y() + yo); }
private:
double x_;
double y_;
};
Listing 36-1.The point Class with Access-Level Specifiers
数据成员是私有的,所以唯一可以修改它们的函数是point
自己的成员函数。公共成员函数通过公共成员函数x()
和y()
提供对职位的访问。
Tip
始终保持数据成员私有,并且只通过成员函数提供访问。
要修改位置,请注意point
不允许用户任意分配新的 x 或 y 值。相反,它提供了几个公共成员函数来将点移动到绝对位置或相对于当前位置。
公共成员函数允许您在笛卡尔坐标中工作——即熟悉的 x 和 y 位置,或者在极坐标中工作,指定一个位置作为角度(相对于 x 轴)和离原点的距离。点的两种表示都有其用途,并且都可以唯一地指定二维空间中的任何位置。一些用户更喜欢极坐标符号,而另一些用户更喜欢笛卡尔坐标。两个用户都不能直接访问数据成员,所以point
类如何存储坐标并不重要。事实上,只需更改几个成员函数,就可以更改point
的实现,将距离和角度存储为数据成员。您需要更改哪些成员函数?
将数据成员从x_
和y_
更改为r_
和angle_
需要更改x
、y
、angle
和distance
成员函数,只是为了访问数据成员。你还得改变两个move
功能:move_polar
和move_cartesian
。最后,您必须修改构造器。没有必要进行其他更改。因为scale
和offset
函数不直接访问数据成员,而是调用其他成员函数,所以它们不受类实现变化的影响。重写 point
**类,在其数据成员中存储极坐标。**比较你的类和我的类,如清单 36-2 所示。
struct point
{
public:
point() : point{0.0, 0.0} {}
point(double x, double y) : r_{0.0}, angle_{0.0} { move_cartesian(x, y); }
point(point const&) = default;
double x() const { return distance() * std::cos(angle()); }
double y() const { return distance() * std::sin(angle()); }
double angle() const { return angle_; }
double distance() const { return r_; }
void move_cartesian(double x, double y)
{
move_polar(std::sqrt(x*x + y*y), std::atan2(y, x));
}
void move_polar(double r, double angle)
{
r_ = r;
angle_ = angle;
}
void scale_cartesian(double s) { scale_cartesian(s, s); }
void scale_cartesian(double xs, double ys)
{
move_cartesian(x() * xs, y() * ys);
}
void scale_polar(double r) { move_polar(distance() * r, angle()); }
void rotate(double a) { move_polar(distance(), angle() + a); }
void offset(double o) { offset(o, o); }
void offset(double xo, double yo) { move_cartesian(x() + xo, y() + yo); }
private:
double r_;
double angle_;
};
Listing 36-2.The point Class Changed to Store Polar Coordinates
一个小困难是构造器。理想情况下,point
应该有两个构造器,一个取极坐标,一个取笛卡尔坐标。问题是两组坐标都是成对的数字,重载不能区分参数。这意味着不能对这些构造器使用普通重载。相反,您可以添加第三个参数:一个标志,指示是将前两个参数解释为极坐标还是笛卡尔坐标。
point(double a, double b, bool is_polar)
{
if (is_polar)
move_polar(a, b);
else
move_cartesian(a, b);
}
这有点像黑客攻击,但现在必须这么做。在本书的后面,您将学习更清洁的技术来完成这项任务。
class
对struct
探索 35 暗示了class
关键字以某种方式包含在类定义中,尽管本书中的每个例子都使用了struct
关键字。现在是了解真相的时候了。
道理很简单。struct
和class
关键字都开始类定义。唯一的区别是默认的访问级别:class
的private
和struct
的public
。仅此而已。
按照惯例,程序员倾向于使用class
进行类定义。一个常见的(但不是通用的)惯例是从公共接口开始类定义,将私有成员隐藏在类定义的底部。清单 36-3 展示了point
类的最新版本,这次是使用class
关键字定义的。
class point
{
public:
point() : r_{0.0}, angle_{0.0} {}
double x() const { return distance() * std::cos(angle()); }
double y() const { return distance() * std::sin(angle()); }
double angle() const { return angle_; }
double distance() const { return r_; }
... other member functions omitted for brevity ...
private:
double r_;
double angle_;
};
Listing 36-3.The point Class
Defined with the class Keyword
公立还是私立?
通常,您可以很容易地确定哪些成员应该是公共的,哪些应该是私有的。然而,有时候你必须停下来思考。考虑一下rational
级(最后一次出现在探索 34 )。重写 rational
类以利用访问级别。
你决定将reduce()
公开还是保密?我选择了 private,因为不需要任何外部来电者呼叫reduce()
。相反,唯一可以调用reduce()
的成员函数是那些改变数据成员本身的函数。因此,reduce()
对外部视图是隐藏的,并且用作实现细节。你隐藏的细节越多越好,因为这让你的类更容易使用。
当您添加访问功能时,您是否只让呼叫者更改分子?你写了一个只改变分母的函数吗?还是要求用户同时分配两者?一个rational
对象的用户应该把它当作一个单独的实体,一个数字。你不能只给浮点数分配一个新的指数,也不能只给有理数分配一个新的分子。另一方面,我认为没有理由不让呼叫者只检查分子或分母。例如,您可能想要编写自己的输出格式化函数,这需要分别知道分子和分母。
您做出正确选择的一个好迹象是,您可以轻松地重写所有操作符函数。这些函数应该不必访问rational
的数据成员,而只使用公共函数。如果你试图访问任何私有成员,你很快就会发现编译器不会允许你这样做。这就是隐私的意义。
将您的解决方案与我的解决方案进行比较,如清单 36-4 所示。
#include <cassert>
#include <cstdlib>
import <iostream>;
import <numeric>;
import <sstream>;
/// Represent a rational number (fraction) as a numerator and denominator.
class rational
{
public:
rational(): rational{0} {}
rational(int num): numerator_{num}, denominator_{1} {} // no need to reduce
rational(rational const&) = default;
rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 100000), 100000}
{
reduce();
}
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
float to_float()
const
{
return static_cast<float>(numerator()) / denominator();
}
double to_double()
const
{
return static_cast<double>(numerator()) / denominator();
}
long double to_long_double()
const
{
return static_cast<long double>(numerator()) /
denominator();
}
/// Assign a numerator and a denominator, then reduce to normal form.
void assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
private:
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator() != 0);
if (denominator() < 0)
{
denominator_ = -denominator();
numerator_ = -numerator();
}
int div{std::gcd(numerator(), denominator())};
numerator_ = numerator() / div;
denominator_ = denominator() / div;
}
int numerator_;
int denominator_;
};
/// Absolute value of a rational number.
rational abs(rational const& r)
{
return rational{std::abs(r.numerator()), r.denominator()};
}
/// Unary negation of a rational number.
rational operator-(rational const& r)
{
return rational{-r.numerator(), r.denominator()};
}
/// Add rational numbers.
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{
lhs.numerator() * rhs.denominator() + rhs.numerator() * lhs.denominator(),
lhs.denominator() * rhs.denominator()};
}
/// Subtraction of rational numbers.
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{
lhs.numerator() * rhs.denominator() - rhs.numerator() * lhs.denominator(),
lhs.denominator() * rhs.denominator()};
}
/// Multiplication of rational numbers.
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator()};
}
/// Division of rational numbers.
/// TODO: check for division-by-zero
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator() * rhs.denominator(),
lhs.denominator() * rhs.numerator()};
}
/// Compare two rational numbers for equality.
bool operator==(rational const& a, rational const& b)
{
return a.numerator() == b.numerator() and a.denominator() == b.denominator();
}
/// Compare two rational numbers for inequality.
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
/// Compare two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator() * b.denominator() < b.numerator() * a.denominator();
}
/// Compare two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
/// Compare two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compare two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
/// Read a rational number.
/// Format is @em integer @c / @em integer.
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{}, d{};
char sep{};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(in.failbit);
else if (sep != '/')
{
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(in.failbit);
return in;
}
/// Write a rational numbers.
/// Format is @em numerator @c / @em denominator.
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator() << '/' << rat.denominator();
out << tmp.str();
return out;
}
Listing 36-4.The Latest Rewrite of the rational Class
类是面向对象编程的基本构件之一。现在您已经知道了类是如何工作的,您可以看到它们如何应用于这种风格的编程,这是下一篇文章的主题。
三十七、理解面向对象编程
这个探索从 C++ 编程中脱离出来,转向面向对象编程(OOP)的主题。你可能已经对这个话题很熟悉了,但是我劝你继续读下去。你可能会学到一些新东西。对于其他人来说,这个探索概括地介绍了 OOP 的一些基础。后面的探索将展示 C++ 如何实现 OOP 原则。
书籍和杂志
书和杂志的区别是什么?是的,我很想让你写下你的答案。尽可能多地写下你能想到的不同之处。
书籍和杂志有哪些相似之处?尽可能多地写下你能想到的相似之处。
如果可以的话,把你的清单和其他人写的清单进行比较。他们不一定是程序员;大家都知道什么是书和杂志。问问你的朋友和邻居;在公交车站拦住陌生人,问他们。试着找出一组核心的共性和差异。
清单上的许多项目将是合格的。例如,“大多数书至少有一个作者”,“许多杂志每月出版”,等等。没关系。在解决现实问题时,我们常常根据手头问题的具体需求,将“也许”和“有时”映射为“从不”或“总是”。请记住,这是一个 OOP 练习,而不是书店或库练习。
现在对共同点和不同点进行分类。我不是告诉你如何分类。试着找出一小组涵盖你清单上不同项目的类别。一些不太有用的分类是按字数分组,按最后一个字母分组。试着找到有用的类别。写下来。
我提出了两大类:属性和动作。属性描述书籍和杂志的物理特征:
-
书籍和杂志有大小(页数)和成本。
-
大多数书都有 ISBN(国际标准书号)。
-
大多数杂志都有 ISSN(国际标准序列号)。
-
杂志有卷号和期号。
书籍和杂志都有标题和出版商。书有作者。杂志通常不会。(杂志文章都有作者,但一本杂志整体很少列出一个作者。)
行动描述一本书或一本杂志如何行动,或者你如何与他们互动:
-
你可以看一本书或杂志。一本书或杂志可以打开或合上。
-
你可以购买一本书或杂志。
-
你可以订阅杂志。
属性和动作之间的主要区别在于属性特定于单个对象。动作由一个公共类的所有对象共享。有时,动作被称为行为。所有的狗都表现出气喘吁吁的行为;他们都以几乎相同的方式和相同的原因喘气。所有的狗都有颜色属性,但是一只狗是金色的,另一只狗是黑色的,在树旁边的那只狗是白色带黑色斑点的。
在编程术语中,类描述了该类所有对象的行为或动作以及属性类型。每个对象对于该类枚举的属性都有自己的值。用 C++ 的术语来说,成员函数实现动作并提供对属性的访问,数据成员存储属性。
分类
书和杂志本身没什么作用。相反,他们的“行动”取决于我们如何与他们互动。书店通过销售、储存和广告来与书籍和杂志互动。库的行为包括借出和接受归还。其他种类的对象有自己启动的动作。例如,狗有哪些行为?
狗有哪些属性?
一只猫怎么样?猫和狗有明显不同的行为吗? ____________ 属性?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _总结不同之处。
我不养狗或猫,所以我的观察很有限。从我坐的地方来看,狗和猫有很多相似的属性和行为。我希望许多读者比我更敏锐,能够枚举出这两种动物之间的许多不同之处。
尽管如此,我坚持认为,一旦你仔细考虑这些差异,你会发现它们中的许多并不是某一种动物所特有的属性或行为,而仅仅是某一属性的不同价值或某一行为的不同细节。猫可能更挑剔,但狗和猫都表现出梳理行为。狗和猫有不同的颜色,但它们都有彩色的皮毛(很少有例外)。
换句话说,当试图枚举各种对象的属性和行为时,通过将相似的对象分类在一起,您的工作可以变得更简单。对于生物来说,生物学家已经为我们做了艰苦的工作,他们设计了丰富而详细的动物分类学。因此,一个物种(猫或家猫)属于一个属(猫或犬),是一个科(猫科或犬科)的一部分。这些又进一步分为一个目(食肉目),一个纲(哺乳动物),等等,直到动物界(后生动物)。(分类学家们,请原谅我的过于简单化。)
那么,当你沿着分类树向上爬时,属性和行为会发生什么变化呢?所有哺乳动物的哪些属性和行为是相同的?
所有动物?
随着分类变得更广泛,属性和行为也变得更普遍。狗和猫的属性包括皮毛的颜色、尾巴的长度、体重等等。不是所有的哺乳动物都有皮毛或尾巴,所以你需要整个类有更广泛的属性。重量仍然有效,但是你可能想用尺寸来代替总长度。除了毛发的颜色,你只需要普通的颜色。对所有动物来说,属性都很宽泛:大小、重量、单细胞还是多细胞,等等。
行为都差不多。你可以枚举出猫会咕噜叫,狗会喘气,这两种动物都会走和跑,等等。所有的哺乳动物都吃和喝。雌性哺乳动物哺育幼仔。对所有动物来说,你只剩下一个简短的清单:进食和繁殖。当你试图列出从变形虫到斑马的所有动物的共同行为时,很难比这更具体了。
分类树有助于生物学家了解自然界。类树(通常被称为类层次,因为大词让我们觉得自己很重要)帮助程序员在软件中模拟自然世界(或者模拟非自然世界,这在我们的许多项目中经常发生)。程序员更喜欢任何类层次的局部递归视图,而不是试图命名树的每一层。沿着树向上,每个类都有一个基类,也称为超类或父类。因此,动物是哺乳动物的基类,哺乳动物是狗的基类。向下是派生的类,也称为子类或子类。狗是哺乳动物的派生类。图 37-1 展示了一个类层次结构。箭头从派生类指向基类。
图 37-1。
类图
一个直接基类是没有中间基类的基类。例如, catus 的直接基类是 Felis ,它有一个 Felidae 的直接基类,后者有一个食肉动物的直接基类。后生动物、哺乳动物、食肉动物、猫科动物和猫科动物都是猫科动物的基类,但只有猫科动物是它的直接基类。
继承
正如哺乳动物具有动物的所有属性和行为,狗具有所有哺乳动物的属性和行为一样,在 OOP 语言中,派生类具有其所有基类的所有行为和属性。最常用的术语是继承:派生类继承其基类的行为和属性。这个术语有些不幸,因为 OOP 继承与真实世界的继承完全不同。当派生类继承行为时,基类保留其行为。在现实世界中,类不继承任何东西;物体会。
在现实世界中,一个人对象继承了某些属性(现金、股票、不动产等)的值。)来自一个已故的祖先对象。在 OOP 世界中,person
类通过共享基类中定义的那些行为函数的单个副本,从基类(如primate
)继承行为。一个person
类继承了一个基类的属性,所以派生类的对象包含了在它的类和所有基类中定义的所有属性的值。随着时间的推移,继承术语对您来说会变得很自然。
因为继承创建了一个树状结构,所以树的术语也充斥着对继承的讨论。正如编程中常见的那样,树形图是上下颠倒绘制的,树根在上面,树叶在下面(如图 36-1 所示)。一些 OOP 语言(Java,Smalltalk,Delphi)有一个单一的根,它是所有类的最终基类。其他的,比如 C++,就没有。任何类都可以是自己继承树的根。
到目前为止,继承的主要例子涉及某种形式的特化。猫比哺乳动物更专业,而哺乳动物比动物更专业。计算机编程也是如此。例如,图形用户界面(GUI)的类框架通常使用特化类的层次结构。图 37-2 显示了组成 wxWidgets 的一些更重要的类的选择,wxWidgets 是一个支持许多平台的开源 C++ 框架。
图 37-2。
wxWidgets 类层次结构摘录
即使 C++ 不需要单个根类,但有些框架需要;wxWidgets 确实需要一个根类。大多数 wxWidgets 类都源自wxObject
。有些对象很简单,比如wxPen
和wxBrush
。交互对象源自wxEvtHandler
(“事件处理程序”的简称)。因此,类树中的每一步都引入了另一种程度的特化。
在本书的后面,您将看到继承的其他用途,但最常见也是最重要的用途是从更通用的基类创建专门的派生类。
利斯科夫替代原理
当派生类专门处理基类的行为和属性时(这是常见的情况),您编写的任何涉及基类的代码都应该与派生类的对象一起工作。换句话说,喂养哺乳动物的行为,从广义上来说,是相同的,与具体的动物种类无关。
Barbara Liskov 和 Jeannette Wing 将这一面向对象编程的基本原则形式化,这一原则现在通常被称为替代原则或 Liskov 的替代原则。简而言之,替换原则规定,如果你有基类 B 和派生类 D ,在任何需要类型 B 的对象的情况下,你都可以替换类型 D 的对象,而不会产生不良影响。换句话说,如果你需要一只哺乳动物,任何哺乳动物,有人给你一只狗,你应该可以使用那只狗。如果有人送给你一只猫、一匹马或一头牛,你可以用那种动物。然而,如果有人递给你一条鱼,你可以用任何你认为合适的方式拒绝这条鱼。
替代原则有助于你编写程序,但也带来了负担。它之所以有帮助,是因为它让您可以自由地编写依赖于基类行为的代码,而不用担心任何派生类。例如,在 GUI 框架中,基类wxEvtHandler
可能能够识别鼠标点击并将其分派给事件处理程序。点击处理程序不知道也不关心这个控件实际上是一个wxListCtrl
控件、一个wxTreeCtrl
控件还是一个wxButton
。重要的是wxEvtHandler
接受一个点击事件,获取位置,确定哪个鼠标按钮被点击,等等,然后将这个事件发送给事件处理程序。
责任落在了wxButton
、wxListCtrl
和wxTreeCtrl
类的作者身上,以确保他们的点击行为符合替换原则的要求。满足要求的最简单方法是让派生类继承基类的行为。然而,有时派生类有额外的工作要做。它不是继承,而是提供新的行为。在这种情况下,程序员必须确保该行为是基类行为的有效替代。接下来的几个探索将展示这个抽象原理的具体例子。
类型多态性
在回到 C+±land 之前,我想提出一个更普遍的原则。假设我给你一个标有“哺乳动物”的盒子盒子里可以是任何哺乳动物:狗、猫、人等等。你知道盒子里装不下一只鸟、一条鱼、一块石头或一棵树。里面一定有哺乳动物。程序员称这个盒子为多态的,来自希腊语,意思是“多种形式”。盒子可以容纳许多形式中的任何一种,也就是说,任何一种哺乳动物,不管它是哪种形式的哺乳动物。
虽然很多程序员使用的是通用术语多态性,但是这种具体的多态性是类型多态性,也称为子类型多态性。也就是说,变量(或框)的类型决定了它可以包含哪些类型的对象。多态变量(或盒子)可以包含多种对象类型中的一种。
特别是,具有基类类型的变量可以引用基类类型的对象或从该基类派生的任何类型的对象。根据替换原则,您可以编写代码来使用基类变量,调用基类的任何成员函数,并且该代码将工作,而不管对象的真实派生类型。
既然您已经对 OOP 的原理有了基本的理解,那么是时候看看它们在 C++ 中是如何发挥作用的了。
三十八、继承
前面的探索介绍了一般的 OOP 原则。现在是时候看看如何将这些原则应用到 C++ 中了。
驾驶课程
定义派生类就像定义任何其他类一样,除了在冒号后包含基类访问级别和名称。参见清单 38-1 中一些支持库的简单类的例子。库里的每一件物品都是某种作品:一本书、一本杂志、一部电影等等。为了简单起见,类work
只有两个派生类,book
和periodical
。
import <iostream>;
import <string>;
import <string_view>;
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_{id}, title_{title} {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
private:
std::string id_;
std::string title_;
};
class book : public work
{
public:
book() : work{}, author_{}, pubyear_{} {}
book(book const&) = default;
book(std::string_view id, std::string_view title, std::string_view author,
int pubyear)
: work{id, title}, author_{author}, pubyear_{pubyear}
{}
std::string const& author() const { return author_; }
int pubyear() const { return pubyear_; }
private:
std::string author_;
int pubyear_; ///< year of publication
};
class periodical : public work
{
public:
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
periodical(periodical const&) = default;
periodical(std::string_view id, std::string_view title, int volume,
int number,
std::string_view date)
: work{id, title}, volume_{volume}, number_{number}, date_{date}
{}
int volume() const { return volume_; }
int number() const { return number_; }
std::string const& date() const { return date_; }
private:
int volume_; ///< volume number
int number_; ///< issue number
std::string date_; ///< publication date
};
int main()
{
book b{"1", "Exploring C++ 20", "Ray Lischner", 2020};
periodical p{"2", "The C++ Times", 1, 1, "Jan 1, 2020"};
std::cout << b.title() << '\n' <<
p.title() << '\n';
}
Listing 38-1.Defining a Derived Class
当您使用struct
关键字定义一个类时,默认的访问级别是public
。对于class
关键字,默认为private
。这些关键字也会影响派生类。除了在极少数情况下,public
是这里的正确选择,这是我用来编写清单 37-1 中的类的。
同样在清单 38-1 中,注意初始化列表有一些新的东西。派生类可以(也应该)通过列出基类名及其初始化器来初始化它的基类。通过传递正确的参数,可以调用任何构造器。如果在初始化列表中省略了基类,编译器将使用基类的默认构造器。
如果基类没有默认的构造器,你认为会发生什么?
试试看。将work
的默认构造器从= default
改为= delete
,并尝试编译清单 38-1 的代码。会发生什么?
没错;编译器报错。您收到的确切错误消息因编译器而异。我得到了如下的信息:
$ g++ -ansi -pedantic list3801err.cpp
list3801err.cpp: In constructor ‘book::book()’:
list3801err.cpp:17:41: error: use of deleted function ‘work::work()’
book() : work{}, author_{}, pubyear_{0} {}
^
list3801err.cpp:4:3: error: declared here
work() = delete;
^
list3801err.cpp: In constructor ‘periodical::periodical()’:
list3801err.cpp:33:56: error: use of deleted function ‘work::work()’
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
^
list3801err.cpp:4:3: error: declared here
work() = delete;
^
基类总是在成员之前初始化,从类树的根开始。您可以通过编写打印来自其构造器的消息的类来了解这一点,如清单 38-2 所示。
import <iostream>;
class base
{
public:
base() { std::cout << "base\n"; }
};
class middle : public base
{
public:
middle() { std::cout << "middle\n"; }
};
class derived : public middle
{
public:
derived() { std::cout << "derived\n"; }
};
int main()
{
derived d;
}
Listing 38-2.Printing Messages from Constructors to Illustrate Order of Construction
您期望清单 38-2 中的程序产生什么输出?
试试看。你实际得到了什么输出?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 为了彻底起见,我收到以下内容:
base
middle
derived
记住,如果你从初始化器中省略了基类,或者你完全省略了初始化器列表,基类的默认构造器被调用。清单 38-2 只包含默认构造器,所以发生的事情是derived
的构造器首先调用middle
的默认构造器。middle
的构造器首先调用base
的默认构造器,base
的构造器除了执行它的函数体之外什么也不做。然后它返回,并且middle
的构造器体执行并返回,最后让derived
运行它的函数体。
成员函数
派生类继承基类的所有成员。这意味着派生类可以调用任何公共成员函数并访问任何公共数据成员。派生类的任何用户也可以。因此,你可以调用一个book
对象的id()
和title()
函数,并且调用work::id()
和work::title()
函数。
访问级别影响派生类,因此派生类不能访问基类的任何私有成员。(在探索 69 中,你将会学到第三个访问级别,当授予对派生类的访问权限时,它保护成员免受外界窥探。)因此,periodical
类不能访问id_
或title_
数据成员,因此派生类不能意外更改work
的身份或标题。这样,访问级别确保了类的完整性。只有声明数据成员的类才能更改它,因此它可以验证所有更改、阻止更改,或者控制谁更改值以及如何更改。
如果派生类声明了与基类同名的成员函数,则派生类函数是派生类中唯一可见的函数。派生类中的函数被称为影子基类中的函数。通常,您希望避免这种情况,但是在一些情况下,您非常希望使用相同的名称,而不隐藏基类函数。在接下来的探索中,您将了解一个这样的案例。以后,你会学习别人。
析构函数
当一个对象被销毁时——可能是因为定义它的函数结束并返回——有时您必须做一些清理工作。一个类有另一个特殊的成员函数,当一个对象被销毁时执行清理。这个特殊的成员函数被称为析构函数。
像构造器一样,析构函数也没有返回值。析构函数名是类名前面加一个波浪号(~
)。清单 38-3 向清单 38-2 中的示例类添加了析构函数。
import <iostream>;
class base
{
public:
base() { std::cout << "base\n"; }
~base() { std::cout << "~base\n"; }
};
class middle : public base
{
public:
middle() { std::cout << "middle\n"; }
~middle() { std::cout << "~middle\n"; }
};
class derived : public middle
{
public:
derived() { std::cout << "derived\n"; }
~derived() { std::cout << "~derived\n"; }
};
int main()
{
derived d;
}
Listing 38-3.Order of Calling Destructors
你期望清单 37-3 中的程序输出什么?
试试看。你实际得到了什么?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 当一个函数返回时,它以构造的逆序销毁所有本地对象。当析构函数运行时,它首先通过运行析构函数的函数体来销毁派生类。然后它调用直接基类析构函数。因此,在下面的示例中,析构函数以相反的构造顺序运行:
base
middle
derived
~derived
~middle
~base
如果你不写析构函数,编译器会为你写一个简单的。无论是您自己编写析构函数,还是编译器隐式编写析构函数,在每个析构函数体完成后,编译器都会为每个数据成员调用析构函数,然后为基类执行析构函数,从派生程度最高的析构函数开始。对于这些例子中的简单类,编译器的析构函数工作得很好。稍后,你会发现析构函数更有趣的用法。目前,主要目的只是可视化对象的生命周期。
仔细阅读清单 38-4 。
import <iostream>;
class base
{
public:
base(int value) : value_{value} { std::cout << "base(" << value << ")\n"; }
base() : base{0} { std::cout << "base()\n"; }
base(base const& copy)
: value_{copy.value_}
{ std::cout << "copy base(" << value_ << ")\n"; }
~base() { std::cout << "~base(" << value_ << ")\n"; }
int value() const { return value_; }
base& operator++()
{
++value_;
return *this;
}
private:
int value_;
};
class derived : public base
{
public:
derived(int value): base{value} { std::cout << "derived(" << value << ")\n"; }
derived() : base{} { std::cout << "derived()\n"; }
derived(derived const& copy)
: base{copy}
{ std::cout << "copy derived(" << value() << "\n"; }
~derived() { std::cout << "~derived(" << value() << ")\n"; }
};
derived make_derived()
{
return derived{42};
}
base increment(base b)
{
++b;
return b;
}
void increment_reference(base& b)
{
++b;
}
int main()
{
derived d{make_derived()};
base b{increment(d)};
increment_reference(d);
increment_reference(b);
derived a(d.value() + b.value());
}
Listing 38-4.Constructors and Destructors
在表格 38-1 的左栏中填入您期望的程序输出。
表 38-1。
运行清单 38-4 中程序的预期和实际结果
|预期产出
|
实际输出
|
— | — |
---|
试试看。将实际输出填入表格 38-1 的右栏,并将两栏进行比较。你把一切都弄对了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
接下来是我的系统生成的输出,以及一些注释。请记住,编译器在优化复制构造器的额外调用方面有一些余地。您可能会得到一个或两个额外的拷贝调用。
base(42) // inside make_derived()
derived(42) // finish constructing in make_derived()
copy base(42) // copy to b in call to increment()
copy base(43) // copy return value from increment to b in main
~base(43) // destroy temporary return value
base(87) // construct a in main
derived(87) // construct a in main
~derived(87) // end of main: destroy a
~base(87) // destroy a
~base(44) // destroy b
~derived(43) // destroy d
~base(43) // finish destroying d
注意引用传递(increment_reference
)没有调用任何构造器,因为没有构造任何对象。相反,引用被传递给函数,被引用的对象递增。
顺便说一下,我还没有向您展示如何重载 increment 操作符,但是您可能已经猜到了它是如何工作的(在类base
中)。减量也差不多。
访问级
在本文开始时,我建议您在基类名称前使用public
,但从未解释过为什么。现在是时候告诉你细节了。
访问级别影响继承的方式与影响成员的方式相同。当您使用struct
关键字定义一个类或者在基类名称前使用public
关键字时,公共继承就会发生。公共继承意味着派生类继承基类的每个成员,其访问级别与基类中的成员相同。除了极少数情况,这正是你想要的。
私有继承发生在你使用private
关键字的时候,当你使用class
关键字定义一个类的时候,它是默认的。私有继承保持基类的每个成员都是私有的,派生类的用户无法访问。必要时编译器仍然调用基类的构造器和析构函数,派生类仍然继承基类的所有成员。派生类可以调用基类的任何公共成员函数,但是其他人不能通过派生类调用它们。这就好像派生类将所有继承的成员重新声明为private
。私有继承允许派生类使用基类,而不需要满足替换原则。这是一种先进的技术,我建议你只有在适当的成人监督下才能尝试。
如果编译器抱怨不可访问的成员,很可能是您忘记了在类定义中包含一个public
关键字。尝试编译清单 38-5 来理解我的意思。
class base
{
public:
base(int v) : value_{v} {}
int value() const { return value_; }
private:
int value_;
};
class derived : base
{
public:
derived() : base{42} {}
};
int main()
{
base b{42};
int x{b.value()};
derived d{};
int y{d.value()};
}
Listing 38-5.Accidentally Inheriting Privately
编译器发出一个错误消息,抱怨base
是私有的或者不能从derived
访问,或者类似的事情。
程序设计式样
如果有疑问,请将数据成员和成员函数设为私有,除非您知道需要将成员设为公共。一旦一个成员成为公共接口的一部分,任何使用你的类的人都可以自由地使用这个成员,你就多了一个代码依赖。更改公共成员意味着找到并修复所有这些依赖关系。让公共接口尽可能小。如果您以后必须添加成员,您可以,但是要移除成员或将其从公共更改为私有要困难得多。每当您必须添加成员来支持公共接口时,请将支持函数和数据成员设为私有。
使用公共继承,而不是私有继承。请记住,继承的成员也成为派生类的公共接口的一部分。如果您更改了基类,您可能需要在派生类中编写额外的成员,以弥补在原始基类中但在新基类中缺少的成员。下一篇文章继续讨论派生类如何与基类一起提供重要的功能。
三十九、虚函数
衍生类很有趣,但是你不能用它们做很多事情——至少现在不能。下一步是了解 C++ 如何实现类型多态性,这一探索将带您踏上旅程。
类型多态性
回想一下 Exploration 37 中的内容,类型多态性是指 B 类型的一个变量能够采用从 B 派生的任何类的“形式”。一个明显的问题是“如何做到?”C++ 的关键是使用一个神奇的关键字在基类中声明一个成员函数,并且用一个不同的神奇的字在派生类中实现这个函数。magic 关键字告诉编译器你想调用类型多态,编译器实现多态魔术。定义一个基类引用类型的变量,并用派生类类型的对象初始化它。当您为对象调用多态函数时,编译后的代码会检查对象的实际类型,并调用该函数的派生类实现。把一个函数变成多态函数的神奇词是virtual
。派生类用override
标记。
例如,假设您希望能够使用标准(或多或少)书目格式打印库中的任何类型的作品(参见清单 38-1 )。对于书籍,我使用的格式是
作者、书名、年份。
对于期刊,我使用
标题、卷(号)、日期。
给每个类添加一个 print
成员函数,打印这些信息。因为这个函数在每个派生类中有不同的行为,所以函数是多态的,所以在print
的基类声明之前使用virtual
关键字,在每个派生类声明之后使用override
,如清单 39-1 所示。
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_{id}, title_{title} {}
virtual ~work() {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
virtual void print(std::ostream&) const {}
private:
std::string id_;
std::string title_;
};
class book : public work
{
public:
book() : work{}, author_{}, pubyear_{0} {}
book(book const&) = default;
book(std::string_view id, std::string_view title, std::string_view author,
int pubyear)
: work{id, title}, author_{author}, pubyear_{pubyear}
{}
std::string const& author() const { return author_; }
int pubyear() const { return pubyear_; }
void print(std::ostream& out)
const override
{
out << author() << ", " << title() << ", " << pubyear() << ".";
}
private:
std::string author_;
int pubyear_; ///< year of publication
};
class periodical : public work
{
public:
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
periodical(periodical const&) = default;
periodical(std::string_view id, std::string_view title, int volume,
int number,
std::string_view date)
: work{id, title}, volume_{volume}, number_{number}, date_{date}
{}
int volume() const { return volume_; }
int number() const { return number_; }
std::string const& date() const { return date_; }
void print(std::ostream& out)
const override
{
out << title() << ", " << volume() << '(' << number() << "), " <<
date() << ".";
}
private:
int volume_; ///< volume number
int number_; ///< issue number
std::string date_; ///< publication date
};
Listing 39-1.Adding a Polymorphic print Function to Every Class Derived from work
Tip
当在base
类中编写一个存根函数时,比如print()
,省略一个或多个参数名。编译器只需要参数类型。如果一个参数或变量没有被使用,一些编译器会警告你,即使编译器没有发出警告,对阅读你的代码的人来说,这也是一个明确的信息:参数没有被使用。
一个引用了一个work
对象的程序可以调用print
成员函数来打印该作品,并且因为print
是多态的,或者说是虚拟的,C++ 环境执行它的魔法来确保调用正确的print
,这取决于work
对象实际上是一个book
还是一个periodical
。要查看这个演示,请阅读清单 39-2 中的程序。
import <iostream>;
import <string>;
import <string_view>;
// All of Listing 39-1 belongs here
... omitted for brevity ...
void showoff(work const& w)
{
w.print(std::cout);
std::cout << '\n';
}
int main()
{
book sc{"1", "The Sun Also Crashes", "Ernest Lemmingway", 2000};
book ecpp{"2", "Exploring C++", "Ray Lischner", 2020};
periodical pop{"3", "Popular C++", 13, 42, "January 1, 2000"};
periodical today{"4", "C++ Today", 1, 1, "January 13, 1984"};
showoff(sc);
showoff(ecpp);
showoff(pop);
showoff(today);
}
Listing 39-2Calling the print Function
你期望什么输出 ?
试试看。你实际得到的输出是什么?
showoff
函数不需要知道book
或periodical
类。就其本身而言,w
是对一个work
对象的引用。您唯一可以调用的成员函数是那些在work
类中声明的函数。尽管如此,当showoff
调用print
时,如果对象的真实类型是book
或periodical
,它将调用book
的print
或periodical
的print
。
编写一个输出操作符( operator<<
),通过调用其 print
成员函数打印一个 work
**对象。**将您的解决方案与我的解决方案进行比较,如清单 39-3 所示。
std::ostream& operator<<(std::ostream& out, work const& w)
{
w.print(out);
return out;
}
Listing 39-3.Output Operator for Class work
编写输出操作符是完全正常的。只要确定你声明w
为推荐人。多态魔法不会发生在普通对象上,只会发生在引用上。使用这个操作符,您可以将任何从work
派生的对象写入输出流,它将使用其print
函数进行打印。
Tip
关键字const
如果存在,总是在override
之前。虽然说明符,比如virtual
,可以和函数的返回类型自由混合(即使结果很奇怪,比如int virtual long function()
),但是const
限定符和override
说明符必须遵循严格的顺序。
虚函数
由于关键字virtual
,多态函数在 C++ 中被称为虚函数。一旦一个函数被定义为虚拟的,它在每个派生类中都是如此。虚函数在派生类中必须具有相同的名称、相同的返回类型以及相同的参数数量和类型(但参数可以有不同的名称)。
实现虚函数不需要派生类。如果没有,它会像继承非虚函数一样继承基类函数。当一个派生类实现一个虚函数时,据说覆盖了该函数,因为派生类的行为覆盖了从基类继承的行为。
在派生类中,override
说明符是可选的,但有助于防止错误。如果您不小心在派生类中键入了函数名或参数,编译器可能会认为您在定义一个全新的函数。通过添加override
,您告诉编译器您打算覆盖基类中声明的虚函数。如果编译器在基类中找不到匹配的函数,它会发出一条错误消息。
添加一个类, movie
,到类库中。movie
类代表录制在磁带或光盘上的电影或影片。与book
和periodical
一样,movie
类源自work
。为了简单起见,除了从work
继承的成员之外,将movie
定义为具有整数运行时间(以分钟为单位)。暂时不要超越print
。将您的类与清单 39-4 进行比较。
class movie : public work
{
public:
movie() : work{}, runtime_{0} {}
movie(movie const&) = default;
movie(std::string_view id, std::string_view title, int runtime)
: work{id, title}, runtime_{runtime}
{}
int runtime() const { return runtime_; }
private:
int runtime_; ///< running length in minutes
};
Listing 39-4.Adding a Class movie
现在修改测试程序从清单 39-2 创建并打印一个 movie
**对象。**如果你愿意,你可以利用新的输出操作符,而不是调用showoff
。将您的程序与清单 39-5 进行比较。
import <iostream>;
import <string>;
import <string_view>;
// All of Listing 39-1 belongs here
// All of Listing 39-3 belongs here
// All of Listing 39-4 belongs here
... omitted for brevity ...
int main()
{
book sc{"1", "The Sun Also Crashes", "Ernest Lemmingway", 2000};
book ecpp{"2", "Exploring C++", "Ray Lischner", 2006};
periodical pop{"3", "Popular C++", 13, 42, "January 1, 2000"};
periodical today{"4", "C++ Today", 1, 1, "January 13, 1984"};
movie tr{"5", "Lord of the Token Rings", 314};
std::cout << sc << '\n';
std::cout << ecpp << '\n';
std::cout << pop << '\n';
std::cout << today << '\n';
std::cout << tr << '\n';
}
Listing 39-5.Using the New movie Class
你期望最后一行输出是什么?
试试看。你得到了什么?
因为movie
没有覆盖print
,所以它继承了基类work
的实现。在work
类中print
的定义什么也不做,所以打印tr
对象什么也不打印。
通过在电影类 中添加 print
**来解决这个问题。**现在你的movie
类应该看起来类似于清单 39-6 。
class movie : public work
{
public:
movie() : work{}, runtime_{0} {}
movie(movie const&) = default;
movie(std::string_view id, std::string_view title, int runtime)
: work{id, title}, runtime_{runtime}
{}
int runtime() const { return runtime_; }
void print(std::ostream& out)
const override
{
out << title() << " (" << runtime() << " min)";
}
private:
int runtime_; ///< running length in minutes
};
Listing 39-6.Adding a print Member Function to the movie Class
override
关键字在派生类中是可选的,但是强烈建议使用。一些程序员也在派生类中使用virtual
关键字。在 C++ 03 中,这提醒读者派生类函数覆盖了一个虚函数。override
说明符是在 C++ 11 中添加的,它有一个额外的特性,告诉编译器同样的事情,所以编译器可以检查你的工作,如果你犯了一个错误就可以投诉。我敦促你在任何地方都使用override
。
EVOLUTION OF A LANGUAGE
您可能会觉得奇怪,关键字virtual
出现在函数头的开头,而override
出现在结尾。你正在目睹一种语言发展过程中经常需要的妥协。
在最初的标准化之后,override
说明符被添加到语言中。添加override
说明符的一种方法是将其添加到函数说明符列表中,比如virtual
。但是给一门语言添加一个新的关键词是充满困难的。每一个使用override
作为变量或其他用户定义名称的现有程序都会崩溃。全世界的程序员将不得不检查并可能修改他们的软件,以避免这个新的关键字。
所以 C++ 标准委员会设计了一种方法来添加override
而不使其成为保留关键字。函数声明的语法将const
限定符放在一个特殊的位置。这里不允许其他标识符,所以很容易以类似于const
的方式将override
添加到成员函数的语法中,并且没有破坏现有代码的风险。
其他新的语言特性以新的方式使用现有的关键字,例如用于构造器的=default
和=delete
。但是增加了一些新的关键字,它们带来了破坏现有代码的风险。因此,委员会试图选择不太可能与现有用户选择的名字冲突的名字。在本书的后面部分,你会看到一些新关键词的例子,以及特殊语境中特殊词汇的其他新颖用法,避免将这些特殊词汇作为关键词。
参考和切片
清单 39-2 中的showoff
函数和清单 39-3 中的输出操作符将其参数声明为对const work
的引用。如果将它们改为按值传递,您认为会发生什么?
试试看。删除输出运算符声明中的&符号,如下所示:
std::ostream& operator<<(std::ostream& out, work w)
{
w.print(out);
return out;
}
运行清单 39-5 中的测试程序。实际产量是多少?
解释发生的事情。
当您通过值传递参数或将派生类对象赋给基类变量时,您会失去多态性。例如,结果不是一个book
,而是一个真正的、真实的、没有人工成分的work
——没有任何关于book
的记忆。因此,每次输出操作符调用work
的print
版本时,输出操作符都会调用它。这就是为什么程序的输出是一堆空行。当您将一个book
对象传递给输出操作符时,不仅会丢失多态性,还会丢失所有的boo
k-ness。特别是,您会丢失author_
和pubyear_
数据成员。当对象被复制到基类变量时,派生类添加的数据成员被切掉。另一种看待它的方式是:因为派生类成员被切掉了,剩下的只是一个work
对象,所以你不能有多态。同样的事情也发生在赋值上。
work w;
book nuts{"7", "C++ in a Nutshell", "Ray Lischner", 2003};
w = nuts; // slices away the author_ and pubyear_; copies only id_ and title_
编写函数时很容易避免切片(通过引用传递所有参数),但对于赋值来说就比较难处理了。你需要的管理作业的技巧在本书的后面会讲到。现在,我将专注于编写多态函数。
纯虚函数
类work
定义了print
函数,但是该函数没有做任何有用的事情。为了有用,每个派生类必须重写print
。基类的作者,比如work
,可以确保每个派生类都正确地重写一个虚函数,方法是省略函数体,代之以标记= 0
。这些标记将该函数标记为一个纯虚函数,这意味着该函数没有可继承的实现,派生类必须重写该函数。
修改 work
类,使 print
成为纯虚函数。然后删除 book
类的 print
函数,看看会发生什么。会发生什么?
编译器强制执行纯虚函数的规则。一个至少有一个纯虚函数的类被称为抽象。不能定义抽象类型的对象。**修复程序。**新的work
类应该看起来像清单 39-7 。
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_(id), title_(title) {}
virtual ~work() {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
virtual void print(std::ostream& out) const = 0;
private:
std::string id_;
std::string title_;
};
Listing 39-7.Defining work As an Abstract Class
虚拟析构函数
虽然你现在写的大部分类都不需要析构函数,但是我想提一个重要的实现规则。任何有虚函数的类都必须声明它的析构函数也是虚的。这个规则是一个编程指南,而不是一个语义要求,所以当你违反它时,编译器不会通过发出一个消息来帮助你(尽管有些编译器可能会发出警告)。相反,你必须通过自律来执行这条规则。
当你开始编写需要析构函数的类时,我会重复这条规则。如果你自己尝试任何实验,请记住这条规则,否则你的程序可能会遇到微妙的问题——或者不那么微妙的崩溃。
下一个探索继续讨论 C++ 类型系统中的类及其关系。
四十、类和类型
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++ 了解得越多,你就会越欣赏它的美丽和简单。
如您所见,我们的示例变得越来越复杂,对我来说,在一个代码清单中包含所有的示例变得越来越困难。您的下一个任务是了解如何将您的代码分成多个文件,这将使我的工作和您的工作更加容易。这项新任务的第一步是仔细研究声明、定义以及它们之间的区别。