21-4 重载 I/O 运算符

对于包含多个成员变量的类,逐个在屏幕上打印每个变量会很快变得乏味。例如,考虑以下类:

class Point
{
private:
    double m_x{};
    double m_y{};
    double m_z{};

public:
    Point(double x=0.0, double y=0.0, double z=0.0)
      : m_x{x}, m_y{y}, m_z{z}
    {
    }

    double getX() const { return m_x; }
    double getY() const { return m_y; }
    double getZ() const { return m_z; }
};

如果你想将这个类的实例打印到屏幕上,你需要这样做:

Point point { 5.0, 6.0, 7.0 };

std::cout << "Point(" << point.getX() << ", " <<
    point.getY() << ", " <<
    point.getZ() << ')';

当然,将其作为可复用函数实现更为合理。在之前的示例中,你已经看到我们创建过类似这样工作的print()函数:

class Point
{
private:
    double m_x{};
    double m_y{};
    double m_z{};

public:
    Point(double x=0.0, double y=0.0, double z=0.0)
      : m_x{x}, m_y{y}, m_z{z}
    {
    }

    double getX() const { return m_x; }
    double getY() const { return m_y; }
    double getZ() const { return m_z; }

    void print() const
    {
        std::cout << "Point(" << m_x << ", " << m_y << ", " << m_z << ')';
    }
};

虽然这样好多了,但仍有不足之处。由于print()返回空值,因此无法在输出语句中间调用它。相反,你必须这样做:

int main()
{
    const Point point { 5.0, 6.0, 7.0 };

    std::cout << "My point is: ";
    point.print();
    std::cout << " in Cartesian space.\n";
}

如果你能直接输入:

Point point{5.0, 6.0, 7.0};
cout << "My point is: " << point << " in Cartesian space.\n";

并且得到相同的结果。输出不会被拆分成多个语句,也不必记住你给打印函数起的名字。

幸运的是,通过重载<<运算符,你可以做到!


重载 operator<<

重载operaotr<<与重载operator+类似(它们都是二元运算符),区别在于参数类型不同。

考虑表达式 std::cout << point。若运算符为 <<,其操作数是什么?左操作数是 std::cout 对象,右操作数则是 Point 类对象。std::cout 实际上是类型为 std::ostream 的对象。因此,我们的重载函数将呈现如下形式:

// std::ostream is the type for object std::cout
friend std::ostream& operator<< (std::ostream& out, const Point& point);

为我们的Point类实现operator<<相当简单——由于C++已知如何使用operator<<输出双精度数,且我们的成员变量均为双精度类型,因此只需直接使用operator<<输出Point类的数据成员即可。以下是包含重载operator<<的Point类实现:

#include <iostream>

class Point
{
private:
    double m_x{};
    double m_y{};
    double m_z{};

public:
    Point(double x=0.0, double y=0.0, double z=0.0)
      : m_x{x}, m_y{y}, m_z{z}
    {
    }

    friend std::ostream& operator<< (std::ostream& out, const Point& point);
};

std::ostream& operator<< (std::ostream& out, const Point& point)
{
    // Since operator<< is a friend of the Point class, we can access Point's members directly.
    out << "Point(" << point.m_x << ", " << point.m_y << ", " << point.m_z << ')'; // actual output done here

    return out; // return std::ostream so we can chain calls to operator<<
}

int main()
{
    const Point point1 { 2.0, 3.0, 4.0 };

    std::cout << point1 << '\n';

    return 0;
}

image

这相当直观——注意我们的输出语句与之前编写的print()函数中的语句多么相似。最显著的区别在于std::cout已成为参数out(函数调用时它将成为对std::cout的引用)。

最棘手的部分在于返回类型。使用算术运算符时,我们通过值传递计算并返回单一结果(因为创建并返回的是新结果)。但若尝试按值返回 std::ostream,编译器会报错——这是因为 std::ostream 明确禁止被复制。

因此我们改为将左侧参数作为引用返回。这不仅避免了std::ostream的复制操作,还支持输出命令的链式调用,例如std::cout << point << ‘\n’。

试想若<<运算符返回void类型会发生什么。当编译器解析 std::cout << point << ‘\n’ 时,根据运算符优先级/结合性规则,该表达式会被解析为 (std::cout << point) << ‘\n’;。其中 std::cout << point 会调用返回 void 的重载运算符,返回 void 后,部分解析后的表达式变成 void << ‘\n’;——这显然毫无意义!

若改为将输出参数作为返回类型,(std::cout << point) 将返回 std::cout。此时部分求值后的表达式变为:std::cout << ‘\n’;,该表达式随后将被继续求值!

当需要使重载二元运算符具备此类链式调用特性时,左操作数应通过引用方式返回。在此情况下通过引用返回左操作数是可行的——由于左操作数由调用函数传递,被调用函数返回时它必然依然存在。因此我们无需担心操作符返回时引用到已超出作用域而被销毁的对象。

为验证其有效性,请看以下示例,它使用了 Point 类及我们之前编写的重载 operator<<

#include <iostream>

class Point
{
private:
    double m_x{};
    double m_y{};
    double m_z{};

public:
    Point(double x=0.0, double y=0.0, double z=0.0)
      : m_x{x}, m_y{y}, m_z{z}
    {
    }

    friend std::ostream& operator<< (std::ostream& out, const Point& point);
};

std::ostream& operator<< (std::ostream& out, const Point& point)
{
    // Since operator<< is a friend of the Point class, we can access Point's members directly.
    out << "Point(" << point.m_x << ", " << point.m_y << ", " << point.m_z << ')';

    return out;
}

int main()
{
    Point point1 { 2.0, 3.5, 4.0 };
    Point point2 { 6.0, 7.5, 8.0 };

    std::cout << point1 << ' ' << point2 << '\n';

    return 0;
}

这产生以下结果:

image

在上例中,operator<<被定义为友元,因为它需要直接访问Point类的成员。然而,如果成员可通过获取器访问,则operator<<可作为非友元实现。


重载operator>>

同样可以重载输入运算符。其实现方式与重载输出运算符类似。关键点在于需知晓std::cin是类型为std::istream的对象。以下是添加了重载operator>>的Point类:

#include <iostream>

class Point
{
private:
    double m_x{};
    double m_y{};
    double m_z{};

public:
    Point(double x=0.0, double y=0.0, double z=0.0)
      : m_x{x}, m_y{y}, m_z{z}
    {
    }

    friend std::ostream& operator<< (std::ostream& out, const Point& point);
    friend std::istream& operator>> (std::istream& out, Point& point);
};

std::ostream& operator<< (std::ostream& out, const Point& point)
{
    // Since operator<< is a friend of the Point class, we can access Point's members directly.
    out << "Point(" << point.m_x << ", " << point.m_y << ", " << point.m_z << ')';

    return out;
}

// note that point must be non-const so we can modify the object
std::istream& operator>> (std::istream& in, Point& point)
{
    // This version subject to partial extraction issues (see below)
    in >> point.m_x >> point.m_y >> point.m_z;

    return in;
}

int main()
{
    std::cout << "Enter a point: ";

    Point point{ 1.0, 2.0, 3.0 }; // non-zero test data
    std::cin >> point;

    std::cout << "You entered: " << point << '\n';

    return 0;
}

假设用户输入4.0 5.6 7.26作为输入,程序将产生以下结果:

image

现在让我们看看当用户输入 4.0b 5.6 7.26 时会发生什么(注意 4.0 后的 b):

image

我们的变量现在成了一个奇怪的混合体:包含用户输入的一个值(4.0)、一个被初始化为零的值(0.0),以及一个未被输入函数触及的值(3.0)。这……不太妙!


防范部分提取

当提取单个值时,结果只有两种可能:提取失败或成功。然而,当作为输入操作的一部分提取多个值时,情况就变得复杂了。

上述 operator>> 的实现可能导致部分提取。这正是输入 4.0b 5.6 7.26 出现的问题:对 x_y 的提取成功从用户输入中提取了 4.0,而 b 5.6 7.26 仍保留在输入流中。向m_y的提取未能获取b,因此m_y被赋值0.0并进入失败模式。由于未清除失败状态,对m_z的提取立即中止,m_z在提取尝试前的值(3.0)得以保留。

这种结果在任何情况下都不理想,某些场景下甚至可能引发严重风险。试想若我们正在为Fraction对象编写operator>>:当分子提取成功后,若分母提取失败,分母将被赋值为0.0,这可能导致后续除零错误并引发应用程序崩溃。

那么如何避免这种情况?一种方法是使操作具有事务性。事务性操作transactional operation必须完全成功或完全失败——不允许部分成功或部分失败。这有时被称为“全有或全无”原则。若事务过程中任何环节发生失败,操作此前引发的变更必须全部撤销。

关键洞察:

现实生活中交易时刻发生。以我需要从一个银行账户向另一个账户转账为例,这需要两个步骤:首先从一个账户扣款,然后将款项记入另一个账户。执行此操作时存在三种可能:

  • 扣款步骤失败(例如余额不足)。交易失败,两个账户余额均未反映转账。
  • 入账步骤失败(例如技术故障)。此时需撤销已完成的扣款操作。交易失败,两个账户余额均未反映转账。
  • 两步操作均成功。交易完成,两个账户余额均反映转账。

最终结果仅存在两种可能:转账完全失败且账户余额不变,或转账成功且两个账户余额均发生变化。

让我们将重载的(指的是类Point)operator>>重新实现为事务性操作:

// note that point must be non-const so we can modify the object
// note that this implementation is a non-friend
std::istream& operator>> (std::istream& in, Point& point)
{
    double x{};
    double y{};
    double z{};

    if (in >> x >> y >> z)      // if all extractions succeeded
        point = Point{x, y, z}; // overwrite our existing point

    return in;
}

在此实现中,我们并未直接用用户输入覆盖数据成员。而是将用户输入提取到临时变量(x、y 和 z)中。当所有提取操作完成后,我们会检查是否所有提取均成功。若成功,则同时更新 Point 的所有成员;否则,则不更新任何成员。

提示:
if (in >> x >> y >> z) 等同于 in >> x >> y >> z; if (in)。请记住,每次提取操作都会返回 in,因此可将多个提取操作串联起来。单语句版本使用最后一次提取返回的 in 作为 if 语句的条件,而多语句版本则显式使用 in。

提示
事务性操作可通过多种策略实现。例如:

  • 成功时修改:存储每个子操作的结果。若所有子操作成功,则用存储结果替换相关数据。这是上述Point示例采用的策略。
  • 失败时恢复:复制所有可修改的数据。若任何子操作失败,可通过复制数据撤销先前子操作的变更。
  • 失败时回滚:若任何子操作失败,则通过执行相反子操作撤销所有先前子操作。此策略常见于数据库场景——因数据量过大无法备份,且子操作结果无法存储。

虽然上述operator>>可防止部分提取,但其行为与基本类型中operator>>不一致。当向基本类型对象提取数据失败时,该对象不会保持原状——而是通过复制赋值为0(确保对象在提取尝试前未初始化时仍具有一致值)。因此为保持一致性,您可能希望在提取失败时将对象重置为默认状态(至少在存在默认值的情况下)。

以下是operator>>的替代版本,当任何提取操作失败时将Point对象重置为默认状态:

// note that point must be non-const so we can modify the object
// note that this implementation is a non-friend
std::istream& operator>> (std::istream& in, Point& point)
{
    double x{};
    double y{};
    double z{};

    in >> x >> y >> z;
    point = in ? Point{x, y, z} : Point{};

    return in;
}

作者注:
从技术角度而言,此类操作已不再属于事务性操作(因为失败时并非“什么都不做”)。目前似乎尚无通用术语来描述能保证不产生部分结果的操作。或许可称之为“不可分割操作indivisible operation”。


处理语义无效的输入

提取操作可能以不同方式失败。

operator>>完全无法向变量提取任何内容时,std::cin会自动进入失败模式(详见第9.5节——std::cin与无效输入处理)。此时函数调用方可检查std::cin是否失败,并采取相应处理措施。

但若用户输入的值虽可提取却语义无效(例如分母为零的分数)呢?由于 std::cin 确实提取了内容,它不会自动进入失败模式。此时调用方很可能意识不到问题存在。

为解决此问题,可让重载的operator>>检测提取值是否存在语义错误,若存在则手动将输入流置于失败模式。具体可通过调用std::cin.setstate(std::ios_base::failbit);实现。

以下是 Point 类事务性重载 operator>>的示例,当用户输入可提取的负值时,该运算符将使输入流进入失败模式:

std::istream& operator>> (std::istream& in, Point& point)
{
    double x{};
    double y{};
    double z{};

    in >> x >> y >> z;
    if (x < 0.0 || y < 0.0 || z < 0.0)       // if any extractable input is negative
        in.setstate(std::ios_base::failbit); // set failure mode manually
    point = in ? Point{x, y, z} : Point{};

    return in;
}

image


结论

重载operator<<operator>>使您能够轻松地将类输出到屏幕,并从控制台接受用户输入。


测验时间

问题 #1

请在下方 Fraction 类中添加重载的 operator<< 和 operator>> 运算符。其中 operator>> 运算符应避免部分提取,并在用户输入分母为 0 时失败。失败时不应将 Fraction 重置为默认值。

以下程序应能编译通过:

int main()
{
	Fraction f1{};
	std::cout << "Enter fraction 1: ";
	std::cin >> f1;

	Fraction f2{};
	std::cout << "Enter fraction 2: ";
	std::cin >> f2;

	std::cout << f1 << " * " << f2 << " is " << f1 * f2 << '\n'; // note: The result of f1 * f2 is an r-value

	return 0;
}

并输出结果:

Enter fraction 1: 2/3
Enter fraction 2: 3/8
2/3 * 3/8 is 1/4

以下是 Fraction 类:

#include <iostream>
#include <numeric> // for std::gcd

class Fraction
{
private:
	int m_numerator{};
	int m_denominator{};

public:
	Fraction(int numerator=0, int denominator=1):
		m_numerator{numerator}, m_denominator{denominator}
	{
		// We put reduce() in the constructor to ensure any new fractions we make get reduced!
		// Any fractions that are overwritten will need to be re-reduced
		reduce();
	}

	void reduce()
	{
		int gcd{ std::gcd(m_numerator, m_denominator) };
		if (gcd)
		{
			m_numerator /= gcd;
			m_denominator /= gcd;
		}
	}

	friend Fraction operator*(const Fraction& f1, const Fraction& f2);
	friend Fraction operator*(const Fraction& f1, int value);
	friend Fraction operator*(int value, const Fraction& f1);

	void print() const
	{
		std::cout << m_numerator << '/' << m_denominator << '\n';
	}
};

Fraction operator*(const Fraction& f1, const Fraction& f2)
{
	return Fraction { f1.m_numerator * f2.m_numerator, f1.m_denominator * f2.m_denominator };
}

Fraction operator*(const Fraction& f1, int value)
{
	return Fraction { f1.m_numerator * value, f1.m_denominator };
}

Fraction operator*(int value, const Fraction& f1)
{
	return Fraction { f1.m_numerator * value, f1.m_denominator };
}

若您使用的是 C++17 之前的编译器,可将 std::gcd 替换为以下函数:

#include <cmath>

int gcd(int a, int b) {
    return (b == 0) ? std::abs(a) : gcd(b, a % b);
}

显示答案

#include <iostream>
#include <limits>
#include <numeric> // for std::gcd

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    Fraction(int numerator=0, int denominator = 1) :
        m_numerator{ numerator }, m_denominator{ denominator }
    {
        // We put reduce() in the constructor to ensure any new fractions we make get reduced!
        // Any fractions that are overwritten will need to be re-reduced
        reduce();
    }

    void reduce()
    {
        int gcd{ std::gcd(m_numerator, m_denominator) };
        if (gcd)
        {
            m_numerator /= gcd;
            m_denominator /= gcd;
        }
    }

    friend Fraction operator*(const Fraction& f1, const Fraction& f2);
    friend Fraction operator*(const Fraction& f1, int value);
    friend Fraction operator*(int value, const Fraction& f1);

    friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);

    void print() const
    {
        std::cout << m_numerator << '/' << m_denominator << '\n';
    }
};

Fraction operator*(const Fraction& f1, const Fraction& f2)
{
    return Fraction { f1.m_numerator * f2.m_numerator, f1.m_denominator * f2.m_denominator };
}

Fraction operator*(const Fraction& f1, int value)
{
    return Fraction { f1.m_numerator * value, f1.m_denominator };
}

Fraction operator*(int value, const Fraction& f1)
{
    return Fraction { f1.m_numerator * value, f1.m_denominator };
}

std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
    out << f1.m_numerator << '/' << f1.m_denominator;
    return out;
}

std::istream& operator>>(std::istream& in, Fraction& f1)
{
    int numerator {};
    char ignore {};
    int denominator {};

    in >> numerator >> ignore >> denominator;
    if (denominator == 0)                       // if our denominator is semantically invalid
        in.setstate(std::ios_base::failbit);    // set failure mode manually
    if (in)                                     // if we're not in failure mode
        f1 = Fraction {numerator, denominator}; // update our object to the extracted values

    return in;
}

int main()
{
    Fraction f1{};
    std::cout << "Enter fraction 1: ";
    std::cin >> f1;

    Fraction f2{};
    std::cout << "Enter fraction 2: ";
    std::cin >> f2;

    std::cout << f1 << " * " << f2 << " is " << f1 * f2 << '\n'; // note: The result of f1 * f2 is an r-value

    return 0;
}
posted @ 2026-01-22 09:54  游翔  阅读(0)  评论(0)    收藏  举报