探索-C--20-十一-

105 阅读51分钟

探索 C++20(十一)

原文:Exploring C++20

协议:CC BY-NC-SA 4.0

六十八、枚举

C++ 中定义类型的最后一个机制是enum关键字,它是枚举的缩写。枚举有两种风格。一种风味起源于 C,有一些奇怪的怪癖。另一种风格解决了这些怪癖,可能对你更有意义。这种探索从新口味开始。

限定范围的枚举

枚举类型是用户定义的类型,它将一组标识符定义为该类型的值。用enum class关键字定义一个枚举类型,后跟新类型的名称,可选的冒号和整数类型,再加上花括号中的枚举文字。随意用struct代替class;它们在enum声明中是可以互换的。以下代码显示了枚举类型的一些示例:

enum class color { black, red, green, yellow, blue, magenta, cyan, white };
enum class sign : char { negative, positive };
enum class flags : unsigned { boolalpha, showbase, showpoint, showpos, skipws };
enum class review : int { scathing = -2, negative, neutral, positive, rave };

限定了作用域的enum定义定义了一个全新的类型,它不同于所有其他类型。类型名也是作用域名,所有枚举器的名称都在该作用域中声明。因为枚举数是限定了作用域的,所以可以在多个限定了作用域的枚举中使用同一个枚举数名称。

冒号后的类型必须是整数类型。如果省略冒号和 type,编译器会隐式使用int。这种类型被称为底层类型。枚举值被存储,就像它是基础类型的值一样。

每个枚举器命名一个编译时常量。每个枚举数的类型都是枚举类型。通过将枚举类型强制转换为其基础类型,可以获得整数值,并且可以将整数类型强制转换为枚举类型。编译器将而不是自动执行这些转换。清单 68-1 展示了一种实现std::ios_base::openmode类型的方法(在 Exploration 14 中刷新你的记忆)。该类型必须支持按位运算符来组合outtruncapp等,它可以通过将枚举值转换为unsigned,执行运算,并转换回openmode来提供这些运算符。

enum class openmode : unsigned char {
    in=1, out=2, binary=4, trunc=8, app=16, ate=32
};

openmode operator|(openmode lhs, openmode rhs)
{
   return static_cast<openmode>(
     static_cast<unsigned>(lhs) | static_cast<unsigned>(rhs) );
}

openmode operator&(openmode lhs, openmode rhs)
{
   return static_cast<openmode>(
     static_cast<unsigned>(lhs) & static_cast<unsigned>(rhs) );
}

openmode operator~(openmode arg)
{
   return static_cast<openmode>( ~static_cast<unsigned>(arg) );
}

Listing 68-1.One Way to Implement openmode Type

声明枚举数时,可以通过在枚举数名称后跟等号和常量表达式来提供整数值。表达式可以使用先前在同一类型中声明的枚举数作为整型常量。如果您省略了一个值,编译器会将前一个值加 1。如果省略第一个枚举数的值,编译器将使用零,例如:

enum class color : unsigned { black, red=0xff0000, green=0x00ff00, blue=0x0000ff,
     cyan = blue|green, yellow=red|green, magenta=red|blue, white=red|blue|green };

可以前向声明枚举类型,类似于前向声明类类型的方式。如果省略枚举数的花括号列表,它会告诉编译器类型名及其基础类型,因此您可以使用该类型来声明函数参数、数据成员等。您可以在单独的声明中提供枚举数:

enum class deferred : short;
enum class deferred : short { example, of, forward, declared, enumeration };

向前声明的枚举也称为不透明声明。使用不透明声明的一种方法是在模块接口中声明类型,但在模块实现中提供枚举数。如果不透明类型是在类或命名空间中声明的,请确保在提供完整的类型定义时限定该类型的名称,例如,如果标头包含

export module demo;
export struct demo {
  enum hidden : unsigned;
};

源文件可能会声明仅供实现使用而非类用户使用的枚举数,如下所示:

module demo;
enum demo::hidden : unsigned { a, b, c };

编译器隐式定义了枚举数的比较运算符。正如你可能期望的那样,它们通过比较基础的整数值来工作。编译器不提供其他运算符,如 I/O、递增、递减等。

作用域枚举的工作方式与人们期望枚举类型的工作方式非常相似。给定“限定范围”的枚举这个名称,您肯定会问自己,“什么是未限定范围的枚举?”你会大吃一惊的。

未分类枚举

未划分的枚举类型定义了一个新的类型,与其他类型不同。新类型是整数位掩码类型,具有一组预定义的掩码值。如果不指定基础类型,编译器会选择内置整型之一;确切的类型是实现定义的。除了必须省略class(或struct)关键字之外,以与作用域枚举相同的方式定义未作用域枚举。

编译器不为枚举类型定义算术运算符,让您自由定义这些运算符。编译器隐式地将未划分范围的枚举值转换为其基础整数值,但是要转换回来,必须使用显式类型强制转换。以带有整数参数的构造器的方式使用枚举类型名,或者使用static_cast

enum color { black, blue, green, cyan, red, magenta, yellow, white };
int c1{yellow};
color c2{ color(c1 + 1) };
color c3{ static_cast<color>(c2 + 1) };

将一个enum称为“位掩码”可能会让你觉得奇怪,但这就是标准如何定义未分类枚举的实现。假设您定义了以下枚举:

enum sample { low=4, high=256 };

类型为sample的对象的允许值是范围sample(0)sample(511)内的所有值。允许的值是能够容纳枚举数中最大和最小位掩码值的位字段中的所有位掩码值。因此,为了存储值 256,枚举类型必须能够存储多达 9 位。一个副作用是,任何 9 位的值对于枚举类型都是有效的,或者是最大为 511 的整数。

您可以为位字段使用枚举类型(Exploration 66 )。您有责任确保位字段足够大,能够存储所有可能的枚举值,如下所示:

struct demo {
  color okay  : 3; // big enough
  color small : 2; // oops, not enough bits, but valid C++
};

编译器会让你声明一个太小的位域;如果你幸运的话,你的编译器会警告你这个问题。

C++ 从 c 继承了这个对enum的定义。最早的实现缺乏一个正式的模型,所以当标准委员会试图确定正式的语义时,他们能做的最好的事情就是捕捉现存编译器的行为。他们后来发明了作用域枚举器,试图提供一种合理的方法来定义枚举。

字符串和枚举

作用域和非作用域枚举的一个不足之处是 I/O 支持。C++ 不会隐式地为枚举创建任何 I/O 运算符。你只能靠自己了。未划分的枚举数可以隐式提升为基础类型并打印为整数,但仅此而已。如果要使用枚举器的名称,必须自己实现 I/O 操作符。一个困难是 C++ 不允许你用任何反射或内省机制来发现字面上的名字。

为了实现可以读写字符串的 I/O 操作符,您必须能够将字符串映射到枚举值,反之亦然。大多数枚举类型的文字数量有限,因此创建一个名称作为键、文字作为值的映射通常是可行的。要将值映射到字符串,请线性搜索所有匹配的值。这很简单,而且对于小映射来说,成本也不高。

你将如何实现一个行为像映射但又允许反向查找的类型?



我推荐从std::unordered_map派生一个类,为反向查找添加一些成员函数。继续编写一个模板类,将枚举类型作为模板参数,并从std::unordered_map派生。你想添加什么方法?清单 68-2 展示了这样做的一种方法。

export module enums;

import <algorithm>;
import <initializer_list>;
import <stdexcept>;
import <string>;
import <type_traits>;
import <unordered_map>;

export template<class T>
concept is_enum = std::is_enum_v<T>;

export template<class Enum>
requires is_enum<Enum>
class enum_map : public std::unordered_map<std::string, Enum>
{
public:
    using enum_type = Enum;
    using super = std::unordered_map<std::string, enum_type>;
    using value_type = super::value_type;
    using key_type = super::key_type;
    using mapped_type = super::mapped_type;
    using const_iterator = super::const_iterator;

    // All the same constructors as the super class.
    using super::super;
    // But initializer lists require a distinct constructor.
    enum_map(std::initializer_list<value_type> list) : super(list) {}

    using super::find;

    // Lookup by enum value. Return iterator or end().
    const_iterator find(mapped_type value) const {
        return std::ranges::find_if(*this, value
        {
            return pair.second == value;
        });
    }

    using super::at;
    // Lookup by enum value. Return reference to key or throw
    key_type const& at(mapped_type value) const {
        if (auto iter = find(value); iter != this->end())
            return iter->first;
        else
            throw std::out_of_range("enum_map::at()");
    }
};

Listing 68-2.Defining a Type That Maps Strings to and from Enumerations

当派生类定义了基类中也定义了的成员函数时,派生类会隐藏或隐藏基类函数,因为编译器一旦在派生类中找到该函数就会停止查找。即使基类有更好的(或正确的)函数参数匹配,只要编译器找到任何具有正确名称的函数,搜索就会停止。using声明将基类函数带入派生类,这样编译器可以找到所有的基类函数,然后选择最合适的一个。因为我们知道键的类型是一个字符串,映射的类型是一个枚举,所以调用find()at()不会有歧义。如果参数是字符串,则调用常规的unordered_map函数,如果参数是枚举,则调用新的enum_map函数。

给定enum_map类型,编写模板 I/O 函数。流操作符没有帮助,因为为了读写字符串或枚举值,还需要enum_map对象。所以把 read() write() 函数改为。看看你的函数是否和清单 68-3 中的函数相似。

import <istream>;
import <ostream>;

template<class Enum>
std::istream& read(std::istream& stream, enum_map<Enum> const& map, Enum& value)
{
    std::string token;
    if (stream >> token)
    {
        value = map.at(token);
    }
    return stream;
}

template<class Enum>
std::ostream& write(std::ostream& stream, enum_map<Enum> const& map, Enum value)
{
    stream << map.at(value);
    return stream;
}

Listing 68-3.Defining a Type That Maps Strings to and from Enumerations

**用简单的枚举类型测试你的代码。**假设你定义了一个language类型,枚举了你最喜欢的计算机语言。编写一个程序,初始化一个const enum_map对象,从cin中读取一些值,并将它们写回cout。捕捉并报告异常,然后继续循环。我的示例程序是清单 68-4 。

import <iostream>;
import enums;

enum class language { apl, low=apl, cpp, haskell, lisp, scala, high=scala };

enum_map<language> const languages{
    { "apl", language::apl },
    { "c++", language::cpp },
    { "haskell", language::haskell },
    { "lisp", language::lisp },
    { "scala", language::scala }
};

int main()
{
    language lang;
    while (std::cin)
    {
        try {
            if (read(std::cin, languages, lang)) {
                write(std::cout, languages, lang);
                std::cout << '\n';
            }
        }
        catch (std::out_of_range const& ex) {
            std::cout << ex.what() << '\n';
        }
    }
}

Listing 68-4.Demonstrating the enum_map Type

宇宙飞船

不,这一节不涉及宇宙飞船的航空电子设备。相反,它告诉你所谓的“宇宙飞船”运算符。更正式的说法是,这个算子的名字叫三向比较算子,不过飞船更好玩。这个比较操作符返回一个枚举值(这就是为什么我还没有提到它)。

宇宙飞船操作符比较两个值,并一次性告诉您一个对象是小于、大于还是等于另一个对象。它还考虑到了对象不可比较的可能性(比如,两个非数字浮点值)。

如果操作数类型允许,操作符返回一个std::strong_ordering值,可以是lessequalgreater。另一种可能是std::partial_ordering,它也提供了lessequalgreater文字,以及unordered。这两种类型都有作用域,所以您需要限定文字。类型在<compare>中定义。

类型可以重载运算符。例如,rational可以实现强排序,如清单 68-5 所示。

import <compare>;

template<class T>
std::strong_ordering operator<=>(rational<T> const& lhs, rational<T> const& rhs)
{
  if (lhs.denominator() == rhs.denominator())
    // The easy case.
    return lhs.numerator() <=> rhs.numerator();
  else
    return lhs.numerator()*rhs.denominator() <=> lhs.denominator()*rhs.numerator();
}

template<class T>
bool operator<(rational<T> const& lhs, rational<T> const& rhs)
{
  return std::strong_ordering::less == (lhs <=> rhs);
}

Listing 68-5.Implementing Three-Way Comparison for rational

重访项目

现在您已经了解了所有关于枚举的知识,考虑一下如何改进以前的一些项目。例如,在 Exploration 36 中,我们为point类编写了一个构造器,它使用一个bool来区分笛卡尔和极坐标系统。因为true是指笛卡尔还是极坐标并不明显,所以更好的解决方案是使用枚举类型,如下所示:

enum class coordinate_system : bool { cartesian, polar };

另一个可以用枚举改进的例子是清单 57-5 中的card类。不要对套装使用int常量,而是使用枚举。您还可以对等级使用枚举。枚举必须指定枚举器:数字卡和acejackqueenking。选择合适的值,以便可以在[2,10]到rank的范围内转换一个整数,并获得所需的值。你必须为suitrank实现operator++。使用枚举的一个主要改进是不再可能弄错suitrank类型。编写您的新的、改进的card类,并将其与清单 68-6 中我的解决方案进行比较。

export module card;

import <istream>;
import <ostream>;

export enum class suit { diamonds, clubs, hearts, spades };
export enum class rank { r2=2, r3, r4, r5, r6, r7, r8, r9, r10, jack, queen, king, ace };

export suit& operator++(suit& s)
{
   if (s == suit::spades)
      s = suit::diamonds;
  else
     s = static_cast<suit>(static_cast<int>(s) + 1);
  return s;
}

export rank operator++(rank& r)
{
   if (r == rank::ace)
      r = rank::r2;
   else
      r = static_cast<rank>(static_cast<int>(r) + 1);
   return r;
}

/// Represent a standard western playing card.
export class card
{
public:
  constexpr card() : rank_(rank::ace), suit_(suit::spades) {}
  constexpr card(rank r, suit s) : rank_{r}, suit_{s} {}

  constexpr void assign(rank r, suit s);
  constexpr suit get_suit() const { return suit_; }
  constexpr rank get_rank() const { return rank_; }
private:
  rank rank_;
  suit suit_;
};

export bool operator==(card a, card b);
export bool operator!=(card a, card b);
export std::ostream& operator<<(std::ostream& out, card c);
export std::istream& operator>>(std::istream& in, card& c);

/// In some games, Aces are high. In other Aces are low. Use different
/// comparison functors depending on the game.
export bool acehigh_compare(card a, card b);
export bool acelow_compare(card a, card b);

/// Generate successive playing cards, in a well-defined order,
/// namely, 2-10, J, Q, K, A. Diamonds first, then Clubs, Hearts, and Spades.
/// Roll-over and start at the beginning again after generating 52 cards.
export class card_generator

{
public:
  card_generator();
  card operator()();
private:
  card card_;
};

Listing 68-6.Improving the card Class with Enumerations

使用枚举还可以改进哪些项目?

六十九、多重继承

与其他一些面向对象的语言不同,C++ 允许一个类有多个基类。这个特性被称为多重继承。其他几种语言允许单个基类,并引入了各种伪继承机制,比如 Java 接口和 Ruby 插件和模块。C++ 中的多重继承是所有这些行为的超集。

多个基类

通过在逗号分隔的列表中列出所有基类来声明多个基类。每个基类都有自己的访问说明符,如下所示:

class derived : public base1, private base2, public base3
{};

与单一继承一样,派生类可以访问其所有基类的所有非私有成员。派生类构造器按照声明的顺序初始化所有基类。如果你必须传递参数给任何基类构造器,在初始化列表中做。与数据成员一样,初始值设定项的顺序无关紧要。只有声明的顺序是重要的,如清单 69-1 所示。

import <iostream>;
import <string>;
import <utility>;

class visible {
public:
    visible(std::string msg) : msg_{std::move(msg)} { std::cout << msg_ << '\n'; }
    std::string const& msg() const { return msg_; }
private:
    std::string msg_;
};

class base1 : public visible {
public:
   base1(int x) : visible{"base1 constructed"}, value_{x} {}
   int value() const { return value_; }
private:
   int value_;
};

class base2 : public visible {
public:
   base2(std::string const& str) : visible{"base2{" + str + "} constructed"} {}
};

class base3 : public visible {
public:
   base3() : visible{"base3 constructed"} {}
   int value() const { return 42; }
};

class derived : public base1, public base2, public base3 {
public:
   derived(int i, std::string const& str) : base3{}, base2{str}, base1{i} {}
   int value() const { return base1::value() + base3::value(); }
   std::string msg() const
  {
     return base1::msg() + "\n" + base2::msg() + "\n" + base3::msg();
  }
};

int main()
{
   derived d{42, "example"};
}

Listing 69-1.Demonstrating the Order of Initialization of Base Classes

当你编译程序时,你的编译器可能会发出警告,指出derived的初始化列表中基类的顺序与初始化器被调用的顺序不匹配。运行该程序演示了基类的顺序控制构造器的顺序,如以下输出所示:

base1 constructed
base2{example} constructed
base3 constructed

图 69-1 展示了清单 69-1 的类层次结构。请注意,base1base2base3类都有自己的visible基类副本。现在不用关注,但是这一点以后会出现,所以要注意。

img/319657_3_En_69_Fig1_HTML.png

图 69-1。

清单 69-1 中类的 UML 图

如果两个或更多的基类有一个同名的成员,如果你想访问那个成员,你必须向编译器指明你指的是哪一个。当您访问派生类中的成员时,通过用所需的基类名称限定成员名称来实现这一点。参见清单 69-1 中derived类的示例。 main() 功能修改为:

int main()
{
   derived d{42, "example"};
   std::cout << d.value() << '\n' << d.msg() << '\n';
}

预测新程序的输出。








将您的结果与我得到的以下输出进行比较:

base1 constructed
base2{example} constructed
base3 constructed
84
base1 constructed
base2{example} constructed
base3 constructed

虚拟基类

有时你不想要一个公共基类的单独副本。相反,您需要公共基类的单个实例,并且每个类共享一个公共实例。要共享基类,在声明基类时插入virtual关键字。virtual关键字可以在访问说明符之前或之后;惯例是先列出来。

Note

C++ 重载了某些关键字,比如staticvirtualdelete。虚拟基类与虚函数没有关系。他们只是碰巧用了同一个关键词。

想象一下,当base1base2base3都从基类派生时,将visible改为虚拟的。你能想到可能会出现的困难吗?


注意,从visible继承的每个类都向visible的构造器传递不同的值。如果您想共享visible的一个实例,您必须选择一个值并坚持使用它。为了实施这一规则,编译器会忽略虚拟基类的所有初始化器,除了它在最具派生类中需要的初始化器(在这种情况下,derived)。因此,要将visible更改为虚拟的,不仅必须更改base1base2base3的声明,还必须更改derived。当derived初始化visible时,它初始化visible的唯一共享实例。**试试看。**您修改后的程序看起来应该类似于清单 69-2 。

import <iostream>;
import <string>;
import <utility>;

class visible {
public:
    visible(std::string msg) : msg_{std::move(msg)} { std::cout << msg_ << '\n'; }
    std::string const& msg() const { return msg_; }
private:
    std::string msg_;
};

class base1 : virtual public visible {
public:
   base1(int x) : visible{"base1 constructed"}, value_{x} {}
   int value() const { return value_; }
private:
   int value_;
};

class base2 : virtual public visible {
public:
   base2(std::string const& str) : visible{"base2{" + str + "} constructed"} {}
};

class base3 : virtual public visible {
public:
   base3() : visible{"base3 constructed"} {}
   int value() const { return 42; }
};

class derived : public base1, public base2, public base3 {
public:
   derived(int i, std::string const& str)

   : base3{}, base2{str}, base1{i}, visible{"derived"}
   {}
   int value() const { return base1::value() + base3::value(); }
   std::string msg() const
   {
     return base1::msg() + "\n" + base2::msg() + "\n" + base3::msg();
   }
};

int main()
{
   derived d{42, "example"};
   std::cout << d.value() << '\n' << d.msg() << '\n';
}

Listing 69-2.Changing the Inheritance of Visible to Virtual

预测来自清单 69-2 的输出。








请注意,visible类现在只初始化一次,初始化它的是derived类。因此,每个班级的留言都是"derived"。这个例子不同寻常,因为我想说明虚拟基类是如何工作的。大多数虚拟基类只定义一个默认构造器。这使派生类的作者不必担心向虚拟基类构造器传递参数。相反,每个派生类都调用默认的构造器;哪个类派生得最多并不重要。

图 69-2 描述了新的类图,使用了虚拟继承。

img/319657_3_En_69_Fig2_HTML.png

图 69-2。

具有虚拟继承的类图

类似 Java 的接口

使用接口编程有一些重要的优势。能够将接口从实现中分离出来使得在不影响其他代码的情况下更改实现变得容易。如果你必须使用接口,在 C++ 中你可以很容易地做到。

C++ 没有接口的正式概念,但是它支持基于接口的编程。Java 和类似语言中接口的本质是接口没有数据成员,成员函数没有实现。回想一下探索 38 这样的函数叫做纯虚函数。因此,接口仅仅是一个普通的类,其中没有定义任何数据成员,并且将所有成员函数声明为纯虚拟的。

例如,Java 有Hashable接口,它定义了hashequalTo函数。清单 69-3 展示了等价的 C++ 类。

class Hashable
{
public:
   virtual ~Hashable();
   virtual unsigned long hash() const = 0;
   virtual bool equalTo(Hashable const&) const = 0;
};

Listing 69-3.The Hashable Interface in C++

任何实现Hashable接口的类都必须覆盖所有的成员函数。例如,HashableString为字符串实现了Hashable,如清单 69-4 所示。

class HashableString : public Hashable
{
public:
   HashableString() : string_{} {}
   ~HashableString() override;
   unsigned long hash() const override;
   bool equalTo(Hashable const&) const override;

    // Implement the entire interface of std::string ...
private:
   std::string string_;
};

Listing 69-4.The HashableString Class

注意HashableString不是std::string派生而来。相反,它封装了一个字符串,并将所有字符串函数委托给它持有的string_对象。

不能从std::string派生的原因和Hashable包含虚拟析构函数的原因是一样的。回想一下 Exploration 39 中的内容,任何至少有一个虚函数的类都应该将其析构函数设为虚拟的。但是std::string没有虚拟析构函数。这是操作原始指针的程序中的一个问题。如果HashableString是从std::string派生的,并且程序的一部分分配了一个新的HashableString对象,而另一部分删除了与类型std::string相同的指针,那么HashableString析构函数永远不会被调用。这似乎是一个很容易避免的问题,事实也确实如此,但是在大型复杂的程序中,对程序的一个部分进行微小的修改,很容易对程序中不相关的部分产生令人惊讶的影响。

如果HashableString不是从std::string派生的,程序如何管理这些哈希字符串?简短的回答是不能。最长的答案是,从 Java 解决方案的角度考虑问题在 C++ 中并不适用,因为 C++ 为这类问题提供了一个更好的解决方案:模板。

界面与模板

正如您所看到的,C++ 支持 Java 风格的接口,但是这种风格的编程会导致困难。有时候,类似 Java 的接口是正确的 C++ 解决方案。然而,在其他情况下,C++ 提供了更好的解决方案,比如模板。

不要写一个HashableString类,而是写一个hash<>类模板,并为任何必须存储在哈希表中的类型指定模板。主模板提供默认行为;专为std::string型的hash<>。通过这种方式,字符串池可以轻松地存储std::string指针并适当地销毁字符串对象,哈希表可以计算字符串的哈希值(以及您必须存储在哈希表中的任何其他内容)。清单 69-5 展示了一种编写hash<>类模板的方法和一种针对std::string的特化。

export module hash;

import <string>;

export template<class T>
class hash
{
public:
   std::size_t operator()(T const& x) const
   {
     return reinterpret_cast<std::size_t>(&x);
   }
};

export template<>
class hash<std::string>
{
public:
   std::size_t operator()(std::string const& str) const
   {
      std::size_t h(0);
      for (auto c : str)
         h = h << 1 | c;
      return h;
   }
};

Listing 69-5.The hash<> Class Template

(对了,标准库提供std::hash,专门针对std::string。在这次探索中,相信你的库的实现会大大优于 toy 的实现。)

这种方法提供了Hashable接口的所有功能,但是在某种程度上允许任何类型都是可散列的,而不放弃任何定义良好的行为。此外,hash()函数不再是虚拟的,甚至可以是一个内联函数。如果在关键性能路径中访问哈希表,那么速度会相当快。

混合食品

在 Ruby 等语言中发现的另一种多重继承方法是混合。mix-in 是一个通常没有数据成员的类,尽管这在 C++ 中并不是必需的(就像在一些语言中一样)。通常,C++ mix-in 是一个类模板,它定义了一些成员函数,这些函数调用模板参数来为这些函数提供输入值。

常见的习惯用法是 mix-in 模板将派生类作为模板参数。mix-in 可以定义返回派生类引用的操作符,以确保派生类的 API 正是用户所期望的。

困惑了吗?你并不孤单。这是 C++ 中一个常见的习惯用法,但是在它变得熟悉和自然之前需要时间。清单 69-6 有助于阐明这种混合是如何工作的。这种混合定义了一个赋值操作符,该操作符按值接受其参数(稍后调用者决定是复制还是移动赋值的源),并将参数与当前值交换。这是定义赋值操作符的几种常见习惯用法之一。

export module mixin;

export template<class T>
class assignment_mixin {
public:
   T& operator=(T rhs)
   {
      rhs.swap(static_cast<T&>(*this));
      return static_cast<T&>(*this);
   }
};

Listing 69-6.The assignment_mixin Class Template

诀窍在于,mix-in 类不是交换*this,而是将自己转换为对模板参数T的引用。这样,mix-in 永远不需要知道任何关于派生类的信息。唯一的要求是,T类必须是可复制的(因此它可以是赋值函数的一个参数)并且有一个swap成员函数。

为了使用assignment_mixin类,使用派生类名称作为模板参数,从assignment_mixin(以及您希望使用的任何其他 mix-in)派生您的类。清单 69-7 展示了一个类如何使用混合的例子。

import <iostream>;
import <string>;
import <utility>;

import mixin; // Listing 69-6

class thing: public assignment_mixin<thing> {
public:
   thing() : value_{} {}
   thing(std::string s) : value_{std::move(s)} {}
   void swap(thing& other) { value_.swap(other.value_); }
   constexpr std::string const& str() const noexcept { return value_; }
private:
   std::string value_;
};

int main()
{
   thing one{};
   thing two{"two"};
   one = two;
   std::cout << one.str() << '\n';
}

Listing 69-7.Using mix-in Class Template

这个 C++ 成语一开始很难理解,我们来分解一下。首先,考虑一下assignment_mixin类模板。像许多其他模板一样,它接受单个模板参数。它定义了一个成员函数,恰好是一个重载的赋值操作符。assignment_mixin没什么特别的。

但是assignment_mixin有一个重要的属性:编译器可以编译模板,即使模板参数是一个不完整的类。编译器不需要扩展赋值操作符,直到它被使用,并且此时,T必须是完整的。但是对于这个阶级本身来说,T可能是不完整的。如果 mix-in 类要声明一个类型为T的数据成员,那么当 mix-in 被实例化时,编译器会要求T是一个完整的类型,因为它必须知道 mix-in 的大小。

换句话说,你可以使用assignment_mixin作为基类,即使模板参数是一个不完整的类。

当编译器处理一个类定义时,一看到类名,它就在当前范围内将该名称记录为不完整的类型。因此,当assignment_mixin<thing>出现在基类列表中时,编译器能够使用不完整类型thing作为模板参数来实例化基类模板。

当编译器到达类定义的末尾时,thing就变成了一个完整的类型。之后,您将能够使用赋值操作符,因为当编译器实例化该模板时,它需要一个完整的类型,并且它已经有了。

受保护访问级别

除了私有和公共访问级别,C++ 还提供了受保护的访问级别。受保护成员只能由类本身和派生类访问。对于所有其他潜在用户来说,受保护的成员是禁区,就像私人成员一样。

大多数成员是私有或公共的。只有在设计类的层次结构,并且希望派生类调用某个成员函数,但不希望其他任何人调用它时,才使用受保护成员。

混合类有时有一个受保护的构造器。这确保了没有人试图构造该类的独立实例。清单 69-8 显示了带有受保护构造器的assignment_mixin

export module mixin;

export template<class T>
class assignment_mixin {
public:
   T& operator=(T rhs)
   {
      rhs.swap(static_cast<T&>(*this));
      return static_cast<T&>(*this);
   }
protected:
  assignment_mixin() {}
};

Listing 69-8.Adding a Protected Constructor to the assignment_mixin Class Template

多重继承也出现在 C++ 标准库中。你知道输入的istream和输出的ostream。库也有iostream,所以单个流可以执行输入和输出。如你所料,iostream来源于istreamostream。唯一的怪癖与多重继承无关:iostream<istream>头中定义。<iostream>标题定义了名称std::cinstd::cout等等。头名是历史的偶然。

下一个探索通过查看策略和特征,继续您对类型的高级研究。

七十、概念、性状和策略

尽管您可能仍然越来越习惯于模板,但是是时候探索一些用于编写模板的常用工具了:概念、性状和策略。用概念编程与特性密切相关,而特性又与策略密切相关。无论是一起还是分开,它们都可能为您引入一种新的编程风格,但是这种风格构成了 C++ 标准库的基础。正如您将在这次探索中发现的,这些技术非常灵活和强大。这篇探索着眼于这些技术以及如何利用它们。

案例研究:迭代器

考虑一下简单的迭代器。考虑std::advance功能(探索 46 )。函数的作用是改变迭代器指向的位置。advance函数对容器类型一无所知;它只知道迭代器。然而不知何故,它知道如果你试图提升一个vector的迭代器,它可以简单地通过向迭代器添加一个整数来实现。但是如果你推进一个list的迭代器,advance函数必须一次步进迭代器一个位置,直到它到达期望的目的地。换句话说,advance函数实现了改变迭代器位置的最佳算法。对于advance函数来说,唯一可用的信息必须来自迭代器本身,关键的信息是迭代器的种类。特别是,只有随机访问和连续迭代器允许通过加法快速前进。所有其他迭代器都必须遵循循序渐进的方法。双向、随机访问和连续迭代器可以向后,但是向前和输入迭代器不能。(输出迭代器需要赋值来产生输出值,所以不能在输出迭代器上使用advance。)那么advance如何知道自己拥有哪种迭代器,如何选择正确的实现呢?

在大多数 OOP 语言中,迭代器将从一个公共基类中派生,该基类将实现一个虚拟的advance函数。advance算法将调用那个虚函数,并让普通的面向对象调度处理细节。或者重载会让你定义多个advance函数,这些函数在基类作为参数类型的使用上有所不同。C++ 当然可以采取任何一种方法,但它没有。

一个简单的解决方案是对重载的advance函数使用约束。所需的三个函数如下:

  • 随机访问和连续迭代器:使用加法

  • 双向迭代器:逐步向前或向后

  • 向前和输入迭代器:仅逐步向前

<iterator>模块定义了几个概念,您可以将它们用作约束,以确保每个高级函数定义只适用于适当的迭代器类型。在查看我在清单 70-1 中的解决方案之前,尝试定义 advance 函数

import <deque>;
import <iostream>;
import <iterator>;
import <list>;
import <string_view>;
import <vector>;

void trace(std::string_view msg)
{
   std::cout << msg << '\n';
}

template<class Iterator, class Distance>
requires std::random_access_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
    trace("random access or contiguous advance");
    iterator += distance;
}

template<class Iterator, class Distance>
requires std::bidirectional_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
    trace("bidirectional iterator");
    for ( ; distance < 0; ++distance)
        --iterator;
    for ( ; distance > 0; --distance)
        ++iterator;
}

template<class Iterator, class Distance>
requires std::input_iterator<Iterator> and std::unsigned_integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
    trace("forward or input iterator");
    for ( ; distance > 0; --distance)
        ++iterator;
}

template<class Iterator, class Distance>
void test(std::string_view label, Iterator iterator, Distance distance)
{
    advance(iterator, distance);
    std::cout << label << *iterator << '\n';
}

int main()
{
    std::deque<int> deque{ 1, 2, 3 };
    test("deque: ", deque.end(), -2);

    std::list<int> list{ 1, 2, 3 };
    test("list: ", list.end(), -2);

    std::vector<int> vector{ 1, 2, 3};
    test("vector: ", vector.end(), -2);

    test("istream: ", std::istream_iterator<int>{}, 2);
}

Listing 70-1.One Possible Implementation of std::advance

另一种技术使用单个函数,但是依靠类型性状来区分不同的迭代器类别。<iterator>模块定义了各种模板类,描述迭代器类型的共同性状或属性。最重要的是std::iterator_traits<T>,它定义了一个成员类型iterator_category。高级函数可以测试该类型,并将其与std::random_access_iterator_tag和其他迭代器标记类型进行比较,以确定迭代器的种类。其他性状更加集中,比如incrementable_traits,它为任何迭代器定义成员difference_type,该成员或者定义自己的difference_type内存,或者允许迭代器减法,在这种情况下difference_type是减法结果的类型。

将迭代器性状与<type_traits>中定义的其他性状类模板相结合,比如std::is_same<T,U>,它决定TU是否是同一类型。在这种情况下更有用的是模板is_base_of<B, D>来测试B是否是D的基类。这有助于您,因为迭代器标签类型形成了功能的类层次结构;因此,如果T是除std::output_iterator_tag之外的任何迭代器标签类型,则std::is_base_of<std::input_iterator_tag, T>为真。当一个性状有一个为真的编译时数据成员value时,该性状为“真”。类型std::true_type是最常见的表达方式。

将性状用于advance函数的最简单方法是使用if constexpr语句。这是一种特殊的条件,在编译时计算。只有当条件为真时,才会编译if constexpr语句体中的代码。清单 70-2 显示了这种以特质为导向的写作风格advance

import <deque>;
import <iostream>;
import <iterator>;
import <list>;
import <string_view>;
import <type_traits>;
import <vector>;

void trace(std::string_view msg)
{
   std::cout << msg << '\n';
}

template<class Iterator, class Distance>
requires std::input_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
  using tag = std::iterator_traits<Iterator>::iterator_category;
  if constexpr(std::is_base_of<std::random_access_iterator_tag, tag>::value)
  {
    trace("random access+ iterator");
    iterator += distance;
  }
  else {
    trace("input+ iterator");
    if constexpr(std::is_base_of<std::bidirectional_iterator_tag,tag>::value)
    {
      while (distance++ < 0)
        --iterator;
    }
    while (distance-- > 0)
        ++iterator;
  }
}

template<class Iterator, class Distance>
void test(std::string_view label, Iterator iterator, Distance distance)
{
    advance(iterator, distance);
    std::cout << label << *iterator << '\n';
}

int main()
{
    std::deque<int> deque{ 1, 2, 3 };
    test("deque: ", deque.end(), -2);

    std::list<int> list{ 1, 2, 3 };
    test("list: ", list.end(), -2);

    std::vector<int> vector{ 1, 2, 3};
    test("vector: ", vector.end(), -2);

    test("istream: ", std::istream_iterator<int>{}, 2);
}

Listing 70-2.Implementing std::advance with Type Traits

类型性状

标题<type_traits>(在 Exploration 53 中首次引入)定义了一套描述类型性状的性状模板。它们的范围从简单的查询,比如std::is_integral<>,告诉你一个类型是否是内置的整型;到更复杂的查询,比如std::is_nothrow_move_constructible<>,告诉你一个类是否有一个noexcept移动构造器。一些性状修改类型,例如std::remove_reference<>,它将int&转换为int

std::move()函数使用类型性状,这只是标准库中类型性状的一种用法。请记住,它所做的只是将左值转换为右值。它使用remove_reference从其参数中去除引用,然后添加&&将结果转换为右值引用,如下所示:

template<class T>
typename std::remove_reference<T>::type&& move(T&& t) noexcept
{
   return static_cast<typename std::remove_reference<T>::type&&>(t);
}

注意type成员 typedef 的使用。这就是类型性状暴露其转换结果的方式。查询性状声明typestd::true_typestd::false_type的 typedef 这些类在编译时声明一个value成员为truefalse。尽管您可以创建一个true_typefalse_type的实例,并在运行时对它们进行评估,但典型的用途是使用它们来特化一个模板。

类型性状通常是定义概念的基础。定义概念是一个高级主题,所以我不会深入讨论它,但是它有助于了解一个概念是如何定义的。知道了类型性状std::is_integral<T>决定了一个类型是否是内置的整数类型之一,您可以如下定义一个概念integral<T>:

template<class T>
concept integral = std::is_integral<T>::value;

概念是为约束指定名称的一种方式。等号后面的值是布尔表达式,将其他概念视为布尔值。例如,已知整数是一个概念,is_signed是一个类型性状,定义概念signed_integral如下:

template<class T>
concept signed_integral = integral<T> and std::is_signed<T>::value;

概念可以很快变得非常复杂。现在,让我们来处理一个更普通的性状类,std::char_traits

案例研究:char_traits

在 C++ 中处理字符的困难之一是char类型可能是有符号的,也可能是无符号的。一个char的大小相对于一个int的大小因编译器而异。有效字符值的范围也因实现的不同而不同,甚至可能在程序运行时改变。一个由来已久的惯例是使用int来存储一个值,这个值可能是一个char或者一个标志文件结束的特殊值,但是标准中没有任何东西支持这个惯例。您可能需要使用unsigned intlong

为了编写可移植的代码,您需要一个 traits 类来为要使用的整数类型提供 typedef、文件结束标记的值等等。这正是char_traits的作用。当您使用std::char_traits<char>::int_type时,您知道您可以安全地存储任何char值或文件结束标记(也就是std::char_traits<char>::eof())。

标准的istream类有一个get()函数,当没有更多的输入时,它返回一个输入字符或特殊的文件结束标记。标准的ostream类提供put(c)来写一个角色。使用这些函数和 char_traits **编写一个函数,将它的标准输入复制到它的标准输出,一次一个字符。**调用eof()获得特殊的文件尾值,调用eq_int_type(a,b)比较字符的两个整数表示是否相等。这两个函数都是char_traits模板的静态成员函数,您必须用所需的字符类型对其进行实例化。调用to_char_type将整数表示转换回char。将您的解决方案与清单 70-3 进行比较。

import <iostream>;
import <string>;        // for char_traits

int main()
{
   using char_traits = std::char_traits<char>; // for brevity and clarity
   char_traits::int_type c{};
   while (c = std::cin.get(), not char_traits::eq_int_type(c, char_traits::eof()))
      std::cout.put(char_traits::to_char_type(c));
}

Listing 70-3.Using Character Traits when Copying Input to Output

首先,注意循环条件。在 C++ 中,逗号有许多用途,分隔函数和模板声明中的参数,分隔函数调用和初始化器中的值,分隔声明中的声明符,等等。在只需要一个表达式的地方,逗号也可以分隔两个表达式;计算第一个子表达式,丢弃结果,然后计算第二个子表达式。整个表达式的结果是第二个子表达式的结果。在这种情况下,第一个子表达式将get()赋值给c,第二个子表达式调用eq_int_type,因此循环条件的结果是来自eq_int_type的返回值,测试c中存储的get的结果是否等于文件结束标记。另一种编写循环条件的方法如下:

not char_traits::eq_int_type(c = std::cin.get(), char_traits::eof())

我不喜欢在表达式中间隐藏赋值,所以在这种情况下我更喜欢使用逗号运算符。其他开发人员对逗号操作符非常反感。他们更喜欢嵌入式作业风格。另一种解决方案是使用for循环代替while循环,如下所示:

for (char_traits::int_type c = std::cin.get();
      not char_traits::eq_int_type(c, char_traits::eof());
      c = std::cin.get())

for循环解决方案的优点是限制了变量c的范围。但是它有重复呼叫std::cin.get()的缺点。这些解决方案中的任何一个都是可以接受的;选择一种风格并坚持下去。

在这种情况下,char_traits似乎让一切变得更加复杂。毕竟,当使用==操作符时,比较两个整数是否相等更加容易和清晰。另一方面,使用成员函数给了库作者增加逻辑的机会,比如检查无效的字符值。

理论上,您可以编写一个char_traits特化,例如,实现不区分大小写的比较。在这种情况下,eq()(比较两个字符是否相等)和eq_int_type()函数肯定需要额外的逻辑。另一方面,您在 Exploration 59 中了解到,不能为许多国际字符集编写这样的性状类,至少在不知道地区的情况下是这样的。

在现实世界中,char_traits的特化很少见。

尽管如此,char_traits类模板还是很有趣。一个纯 traits 类模板将只实现 typedef 成员、静态数据成员,有时还会实现一个返回常量的成员函数,比如char_traits::eof()。这种特质类的另一个好例子是std::numeric_limits。像eq_int_type()这样的函数不是 traits,它描述的是一个类型。相反,它们是策略职能。策略类模板包含指定行为或策略的成员函数。下一节讨论策略。

基于策略的编程

一个策略是一个类或类模板,另一个类模板可以使用它来定制它的行为。性状和策略之间的界限是模糊的,但是对我来说,性状是静态的性状,而策略是动态的行为。在标准库中,string 和 stream 类使用char_traits策略类模板来获得特定于类型的行为,以比较字符、复制字符数组等等。标准库为charwchar_t类型提供了策略实现。

假设您正在尝试编写一个高性能的服务器。在仔细的设计、实现和测试之后,您发现std::string的性能引入了巨大的开销。在您的特定应用中,内存是充足的,但是处理器时间是宝贵的。如果能够切换开关,将您的std::string实现从针对空间优化的实现更改为针对速度优化的实现,岂不是很好?相反,您必须编写自己的满足需要的字符串替换。在编写自己的类时,你最终会重写许多成员函数,比如find_first_of,这些函数与你的特定实现无关,但对大多数字符串实现来说本质上是一样的。真是浪费时间。

想象一下,如果您有一个 string 类模板,它带有一个额外的模板参数,您可以用它来选择字符串的存储机制,根据您的需要替换内存优化或处理器优化的实现,那么您的工作将会多么简单。简而言之,这就是基于策略的编程的全部内容。

在某种程度上,std::string类提供了这种灵活性,因为std::string实际上是针对 char 类型和特定内存分配策略的std: :basic_string的特化。事实上,所有的标准容器模板(除了array)都有一个分配器模板参数。编写新的分配器超出了本书的范围,因此,我们将编写一个更简单的、假设的策略类,它可以指导mystring类的实现。

为了简单起见,本书只实现了std::string的几个成员函数。完成std::string的界面留给读者作为练习。清单 70-4 展示了新的字符串类模板和它的一些成员函数。看一看,你就会明白它是如何利用Storage策略的。

import <algorithm>;
import <string>;

template<class Char, class Storage, class Traits = std::char_traits<Char>>
class mystring {
public:
   using value_type = Char;
   using size_type = std::size_t;
   using iterator = typename Storage::iterator;
   using const_iterator = Storage::const_iterator;

   mystring() : storage_{} {}
   mystring(mystring&&) = default;
   mystring(mystring const&) = default;
   mystring(Storage const& storage) : storage_{storage} {}
   mystring(Char const* ptr, size_type size) : storage_{} {
      resize(size);
      std::copy(ptr, ptr + size, begin());
   }

   static constexpr size_type npos = static_cast<size_type>(-1);

   mystring& operator=(mystring const&) = default;
   mystring& operator=(mystring&&) = default;

   void swap(mystring& str) { storage_.swap(str.storage_); }

   Char operator[](size_type i) const { return *(storage_.begin() + i); }
   Char& operator[](size_type i)      { return *(storage_.begin() + i); }

   void resize(size_type size, Char value = Char()) {
     storage_.resize(size, value);
   }
   void reserve(size_type size)    { storage_.reserve(size); }
   size_type size() const noexcept { return storage_.end() - storage_.begin(); }
   size_type max_size() const noexcept { return storage_.max_size(); }
   bool empty() const noexcept     { return size() == 0; }
   void clear()                    { resize(0); }
   void push_back(Char c)          { resize(size() + 1, c); }

   Char const* data() const        { return storage_.c_str(); }
   Char const* c_str() const       { return storage_.c_str(); }

   iterator begin()              { return storage_.begin(); }
   const_iterator begin() const  { return storage_.begin(); }
   const_iterator cbegin() const { return storage_.begin(); }
   iterator end()                { return storage_.end(); }
   const_iterator end() const    { return storage_.end(); }
   const_iterator cend() const   { return storage_.end(); }

   size_type find(mystring const& s, size_type pos = 0) const {
      pos = std::min(pos, size());
      auto result{ std::search(begin() + pos, end(),
                               s.begin(), s.end(), Traits::eq) };
      if (result == end())
         return npos;
      else
         return static_cast<size_type>(result - begin());
   }

private:
   Storage storage_;
};

template<class Char, class Storage1, class Storage2, class Traits>
bool operator <(mystring<Char, Storage1, Traits> const& a,
                mystring<Char, Storage2, Traits> const& b)
{
   return std::lexicographical_compare(
      a.begin(), a.end(), b.begin(), b.end(), Traits::lt
   );

}

template<class Char, class Storage1, class Storage2, class Traits>
bool operator ==(mystring<Char, Storage1, Traits> const& a,
                 mystring<Char, Storage2, Traits> const& b)
{
   return std::equal(a.begin(), a.end(), b.begin(), b.end(), Traits::eq);
}

Listing 70-4.The mystring Class Template

mystring类依靠Traits来比较字符,依靠Storage来存储字符。Storage策略必须提供迭代器来访问字符本身和一些基本的成员函数(datamax_sizereserveresizeswap),而mystring类提供公共接口,比如赋值操作符和搜索成员函数。

公共比较函数使用标准算法和Traits进行比较。注意比较函数是如何要求它们的两个操作数具有相同的Traits(否则,字符串如何以有意义的方式进行比较呢?)但允许不同的Storage。如果您只想知道两个字符串是否包含相同的字符,那么字符串如何存储它们的内容并不重要。

下一步是编写一些存储策略模板。存储策略在字符类型上参数化。最简单的Storagevector_storage,它将字符串内容存储在一个vector中。回想一下 Exploration 21 中的 C 字符串以空字符结尾。成员函数返回一个指向 C 风格字符数组的指针。为了简化c_str的实现,vector 在字符串内容后存储一个尾随的空字符。清单 70-5 显示了vector_storage的部分实现。您可以自己完成实现。

import <vector>;

template<class Char>
class vector_storage {
public:
   using size_type = std::size_t;
   using value_type = Char;
   using iterator = std::vector<Char>::iterator;
   using const_iterator = std::vector<Char>::const_iterator;

   vector_storage() : string_{1, Char{}} {}

   void swap(vector_storage& storage) { string_.swap(storage.string_); }
   size_type max_size() const { return string_.max_size() - 1; }
   void reserve(size_type size) { string_.reserve(size + 1); }
   void resize(size_type newsize, value_type value) {
      // if the string grows, overwrite the null character, then resize
      if (newsize >= string_.size()) {
         string_[string_.size() - 1] = value;
         string_.resize(newsize + 1, value);
      }
      else
         string_.resize(newsize + 1);
      string_[string_.size() - 1] = Char{};
   }
   Char const* c_str() const { return &string_[0]; }

   iterator begin()             { return string_.begin(); }
   const_iterator begin() const { return string_.begin(); }
   // Skip over the trailing null character at the end of the vector
   iterator end()               { return string_.end() - 1; }
   const_iterator end() const   { return string_.end() - 1; }

private:
   std::vector<Char> string_;
};

Listing 70-5.The vector_storage Class Template

编写vector_storage的唯一困难是 vector 存储一个尾随的空字符,所以c_str函数可以返回一个有效的 C 风格的字符数组。因此,end函数必须调整它返回的迭代器。

存储策略的另一种可能性是array_storage,它就像vector_storage,除了它使用了一个array。通过使用阵列,所有存储都是本地的。数组大小是字符串的最大容量,但字符串大小可以在该最大值范围内变化。 array_storage。将您的结果与我在清单 70-6 中的结果进行比较。

import <algorithm>;
import <stdexcept>;
import <array>;

template<class Char, std::size_t MaxSize>
class array_storage {
public:
   using array_type = std::array<Char, MaxSize>;
   using size_type = std::size_t;
   using value_type = Char;
   using iterator = array_type::iterator;
   using const_iterator = array_type::const_iterator;

   array_storage() : size_(0), string_() { string_[0] = Char(); }

   void swap(array_storage& storage) {
      string_.swap(storage.string_);
      std::swap(size_, storage.size_);
   }
   size_type max_size() const { return string_.max_size() - 1; }
   void reserve(size_type size) {
     if (size > max_size()) throw std::length_error("reserve");
   }
   void resize(size_type newsize, value_type value) {
      if (newsize > max_size())
         throw std::length_error("resize");
      if (newsize > size_)
         std::fill(begin() + size_, begin() + newsize, value);
      size_ = newsize;
      string_[size_] = Char{};
   }
   Char const* c_str() const { return &string_[0]; }

   iterator begin()             { return string_.begin(); }
   const_iterator begin() const { return string_.begin(); }
   iterator end()               { return begin() + size_; }
   const_iterator end() const   { return begin() + size_; }

private:
   size_type size_;
   array_type string_;
};

Listing 70-6.The array_storage Class Template

编写新的字符串类的一个困难是,您还必须编写新的 I/O 函数。不幸的是,这需要相当多的工作,并且需要对流类模板和流缓冲区有很好的理解。处理填充和字段调整很容易,但是对于 I/O 流还有一些微妙之处我还没有介绍,比如与 C stdio 的集成、绑定输入和输出流以便在要求用户输入之前出现提示,等等。所以只需将我在清单 70-7 中的解决方案复制到mystring模块中。

template<class Char, class Storage, class Traits>
std::basic_ostream<Char, Traits>&
  operator<<(std::basic_ostream<Char, Traits>& stream,
             mystring<Char, Storage, Traits> const& string)
{
   typename std::basic_ostream<Char, Traits>::sentry sentry{stream};
   if (sentry)
   {
      bool needs_fill{stream.width() != 0 and string.size() > std::size_t(stream.width())};
      bool is_left_adjust{
         (stream.flags() & std::ios_base::adjustfield) == std::ios_base::left };
      if (needs_fill and not is_left_adjust)
      {
         for (std::size_t i{stream.width() - string.size()}; i != 0; --i)
            stream.rdbuf()->sputc(stream.fill());
      }
      stream.rdbuf()->sputn(string.data(), string.size());
      if (needs_fill and is_left_adjust)
      {
         for (std::size_t i{stream.width() - string.size()}; i != 0; --i)
            stream.rdbuf()->sputc(stream.fill());
      }
   }
   stream.width(0);
   return stream;
}

Listing 70-7.Output Function for mystring

sentry类代表流管理一些簿记。输出函数处理填充和调整。如果你对细节很好奇,可以咨询一下好的参考资料。

input 函数还有一个sentry类,它代表您跳过前导空格。输入函数必须读取字符,直到它到达另一个空白字符或字符串填充或达到宽度限制。我的版本见清单 70-8 。

template<class Char, class Storage, class Traits>
std::basic_istream<Char, Traits>&
  operator>>(std::basic_istream<Char, Traits>& stream,
             mystring<Char, Storage, Traits>& string)
{
   typename std::basic_istream<Char, Traits>::sentry sentry{stream};
   if (sentry)
   {
      std::ctype<Char> const& ctype(
         std::use_facet<std::ctype<Char>>(stream.getloc()) );
      std::ios_base::iostate state{ std::ios_base::goodbit };
      std::size_t max_chars{ string.max_size() };
      if (stream.width() != 0 and std::size_t(stream.width()) < max_chars)
         max_chars = stream.width();
      string.clear();
      while (max_chars-- != 0) {
         typename Traits::int_type c{ stream.rdbuf()->sgetc() };
         if (Traits::eq_int_type(Traits::eof(), c)) {
            state |= std::ios_base::eofbit;
            break; // is_eof
         }
         else if (ctype.is(ctype.space, Traits::to_char_type(c)))
            break;
         else {
            string.push_back(Traits::to_char_type(c));
            stream.rdbuf()->sbumpc();
         }
      }
      if (string.empty())
         state |= std::ios_base::failbit;
      stream.setstate(state);
      stream.width(0);
   }
   return stream;
}

Listing 70-8.Input Function for mystring

break语句立即退出循环。你可能对这句话或类似的话很熟悉。有经验的程序员可能会惊讶,直到现在还没有例子需要这个语句。一个原因是我忽略了错误处理,否则这将是中断循环的一个常见原因。在这种情况下,当输入到达文件结尾或空白时,就该退出循环了。break的伙伴是continue,它立即重复循环。在一个for循环中,continue评估循环头的迭代部分,然后评估条件。我在现实生活中很少需要使用continue,也想不出任何使用continue的合理例子,但我提到它只是为了完整起见。

众所周知,编译器通过将右边操作数的类型mystring与函数参数的类型进行匹配来找到 I/O 操作符。在这个简单的例子中,您可以很容易地看到编译器如何执行匹配并找到正确的函数。在混合中加入一些名称空间,并添加一些类型转换,一切都会变得有点混乱。下一篇文章将更深入地研究名称空间和 C++ 编译器应用的规则,以便找到重载的函数名(或者找不到重载的函数名,因此如何解决这个问题)。

七十一、名称、命名空间和模板

使用名称空间和模板的基础很简单,也很容易学习。利用参数相关查找(ADL)也很简单:在与类相同的名称空间中声明自由函数和操作符。但有时生活并不那么简单。特别是在使用模板时,您可能会陷入奇怪的角落,编译器会发出奇怪而无用的消息,并且您会意识到您应该在基础知识之外花更多的时间来研究名称、名称空间和模板。

详细的规则可能非常复杂,因为它们必须涵盖所有的病理情况,例如,解释为什么下面的内容是合法的(尽管会导致一些编译器警告)以及它的含义

enum X { X };
void XX(enum X X=::X) { if (enum X X=X) X; }

以及为什么以下行为是非法的:

enum X { X } X;
void XX(X X=X) { if (X X=X) X; }

但是 rational 程序员不会以这种方式编写代码,一些常识性的指导方针对简化复杂的规则大有帮助。因此,这种探索提供了比本书早期更多的细节,但忽略了许多挑剔的细节,这些细节只对模糊 C++ 竞赛的参赛者重要。

通用规则

某些规则适用于所有类型的名称查找。(后续部分将研究特定于各种上下文的规则。)基本规则是,编译器在源代码中看到名字时,必须知道名字的意思。

大多数名称必须在文件中(或者在模块或包含的头文件中)比使用名称的位置更早声明。唯一的例外是在成员函数的定义中:一个名字可以是同一个类的另一个成员,即使该成员在类定义中的声明晚于该名字的使用。名称在一个范围内必须是唯一的,重载函数和运算符除外。如果您试图声明两个可能冲突的名称,例如在同一范围内两个同名的变量,或者在单个类中一个同名的数据成员和成员函数,编译器会发出错误。

函数可以有多个同名的声明,遵循重载的规则,即参数数量或类型必须不同,const成员函数的限定必须不同,或者约束必须使编译器能够区分重载的函数。

访问规则(public、private 或 protected)对类成员(嵌套类型和 typedefs、数据成员和成员函数)的名称查找规则没有影响。通常的名字查找规则识别正确的声明,然后编译器才检查是否允许访问名字。

名称是否是虚函数的名称对名称查找没有影响。正常情况下会查找该名称,一旦找到该名称,编译器就会检查该名称是否是虚函数的名称。如果是这样,并且通过引用或指针访问对象,编译器将生成必要的代码来执行实际函数的运行时查找。

模板特化不影响名称查找。编译器寻找主模板的声明。一旦找到,编译器就使用模板参数来决定使用哪个特化(如果有的话)。

模板中的名称查找

模板使名称查找变得复杂。特别是,名称可以依赖于模板参数。这种从属名称与非从属名称具有不同的查找规则。依赖名可以根据模板特化中使用的模板参数改变含义。一个特化可以将名称声明为类型,另一个特化可以将其声明为函数。(当然,这样的编程风格是非常不鼓励的,但是 C++ 的规则必须允许这样的可能性。)

非独立名称的查找遵循定义模板的常用名称查找规则。除了在定义模板的地方应用的正常规则之外,依赖名称的查找可以包括在与模板实例化相关联的名称空间中的查找。根据正在执行的名称查找的种类,后面的部分提供了附加的详细信息。

三种名称查找

C++ 定义了三种名称查找:成员访问操作符;以类名、枚举名或命名空间名开头的名称。还有光秃秃的名字。

  • 类成员访问操作符是.->。左边是一个表达式,它产生类类型的对象、对对象的引用或指向对象的指针。圆点(。)需要一个对象或引用,->需要一个指针或定义了->操作符的对象。右边是成员名称(数据成员或成员函数)。例如,在表达式cout.width(3)中,cout是对象,width是成员名。

  • 一个类、枚举或命名空间名称后面可以跟一个作用域运算符(::)和一个名称,比如std::stringstd::string::npos。该名称被称为由类名、枚举名或命名空间名限定的。范围运算符的左边不能出现其他类型的名称。编译器在类、枚举或命名空间的范围内查找该名称。该名称本身可以是另一个类、枚举数或命名空间名称,也可以是函数、变量或 typedef 名称。比如在std::filesystem::path::value_type中,stdfilesystem是命名空间名,path是类,value_type是成员 typedef。

  • 一个普通的标识符或操作符被称为非限定名。根据上下文,该名称可以是命名空间名称、类型名称、函数名称或对象名称。不同的上下文有稍微不同的查找规则。

接下来的三个部分更详细地描述了每种类型的名称查找。

成员访问运算符

最简单的规则是成员访问操作符。成员访问运算符的左侧(。或->)确定查找的上下文。该对象必须具有类类型(或者指向类类型的指针或引用),并且右边的名称必须是该类或祖先类的数据成员或成员函数。搜索从对象声明的类型开始,继续搜索其基类(或多个类,按照声明的顺序从左到右搜索多个类)及其基类,依此类推,在第一个具有匹配名称的类处停止。

如果名字是一个函数,编译器收集同一个类中同名的所有声明,根据函数和运算符重载的规则选择一个函数。注意,编译器不考虑祖先类中的任何函数。一旦找到姓名,姓名查找就停止。如果您希望基类的名称参与操作符重载,可以在派生类中使用一个using声明,将基类名称带入派生类上下文中。

在成员函数体中,左边的对象可以是关键字this,它是指向成员访问操作符左边对象的指针。如果成员函数是用const限定符声明的,this是指向const的指针。如果基类是模板参数或依赖于模板参数,编译器不知道哪些成员可以从基类继承,直到模板被实例化。你应该使用this->来访问一个继承的成员,告诉编译器这个名字是一个成员名,编译器在实例化模板的时候会查找这个名字。

清单 71-1 展示了成员访问操作符的几种用法。

import <cmath>;
import <iostream>;

template<class T>
class point2d {
public:
   point2d(T x, T y) : x_{x}, y_{y} {}
   virtual ~point2d() {}
   T x() const { return x_; }
   T y() const { return y_; }
   T abs() const { return std::sqrt(x() * x() + y() * y()); }
   virtual void print(std::ostream& stream) const {
      stream << '(' << x() << ", " << y() << ')';
   }
private:
   T x_, y_;
};

template<class T>
class point3d : public point2d<T> {
public:
   point3d(T x, T y, T z) : point2d<T>{x, y}, z_{z} {}
   T z() const { return z_; }
   T abs() const {
      return static_cast<T>(std::sqrt(this->x() * this->x() +
                 this->y() * this->y() +
                 this->z() * this->z()));
   }
   virtual void print(std::ostream& stream) const {
      stream << '(' << this->x() << ", " << this->y() << ", " << z() << ')';
   }
private:
   T z_;
};

template<class T>
std::ostream& operator<<(std::ostream& stream, point2d<T> const& pt)
{
   pt.print(stream);
   return stream;
}

int main()
{
   point3d<int> origin{0, 0, 0};
   std::cout << "abs(origin) = " << origin.abs() << '\n';

   point3d<int> unit{1, 1, 1};
   point2d<int>* ptr{ &unit };
   std::cout << "abs(unit) = " << ptr->abs() << '\n';
   std::cout << "*ptr = " << *ptr << '\n';
}

Listing 71-1.Member Access Operators

main()函数以您习惯的方式使用成员名称查找。这种用法很容易理解,在本书中你一直在使用它。然而,在point3d::abs()中使用成员名称查找更有趣。需要使用this->,因为基类point2d<T>依赖于模板参数T,这意味着编译器在模板被实例化之前不知道基类。只有这样,它才能知道x()y()是从基类继承的还是从其他上下文继承的。当它编译abs()时,它需要知道如何处理x()y(),所以使用this->x()this->y()告诉编译器在模板实例化时期望在基类中找到这些成员函数。如果找不到它们,它将发出一条错误消息。

operator<<函数引用一个point2d实例并调用它的print函数。虚拟函数被分派给真实函数,在本例中是point3d::print。你知道这是如何工作的,所以这只是提醒编译器如何在point2d类模板中查找名字print,因为那是pt函数参数的类型。

合格名称查找

一个限定的名使用了作用域(::)操作符。从第一个程序开始,你就使用了限定名。名称std::string是限定的,这意味着在由std::限定符指定的上下文中查找名称string。在这个简单的例子中,std命名了一个名称空间,所以在这个名称空间中查找string

限定符也可以是类名或限定范围的枚举的名称。类名可以嵌套,所以作用域操作符的左边和右边可能是类名。如果左边的名称是枚举类型的名称,那么右边的名称必须是该类型的枚举数。

编译器从最左边的名字开始搜索。如果最左边的名字以一个作用域操作符开始(例如,::std::string),编译器会在全局作用域中查找这个名字。否则,它使用非限定名称的常规名称查找规则(如下一节所述)来确定将用于范围运算符右侧的范围。如果右边的名称后面跟有另一个作用域运算符,则所标识的名称必须是命名空间、类或作用域枚举的名称,编译器会在该作用域中查找右边的名称。这个过程一直重复,直到编译器找到最右边的名字。

在一个名称空间中,using 指令告诉编译器在目标名称空间以及包含using指令的名称空间中进行搜索。在下面的例子中,限定名ns2::Integer告诉编译器在名称空间ns2中搜索名字Integer。因为ns2包含一个using指令,编译器也在名称空间ns1中搜索,因此找到了Integer typedef。

namespace ns1 { typedef int Integer; }
namespace ns2 { using namespace ns1; }
namespace ns3 { ns2::Integer x; }

一个using 声明略有不同。一个using指令影响编译器搜索哪些名称空间来找到一个名字。一个using声明并没有改变要搜索的名称空间集,而只是给包含它的名称空间增加了一个名字。在下面的例子中,using声明将名称Integer带入名称空间ns2,就像 typedef 是在ns2中编写的一样。

namespace ns1 { typedef int Integer; }
namespace ns2 { using ns1::Integer; }
namespace ns3 { ns2::Integer x; }

当一个名字依赖于一个模板参数时,编译器必须知道这个名字是属于一个类型还是其他类型(函数或对象),因为它影响编译器如何解析模板体。因为名称是依赖的,所以它可能是一个特化中的类型,也可能是另一个特化中的函数。所以你必须告诉编译器会发生什么。如果名字应该是一个类型,在限定名前面加上关键字typename。如果没有typename关键字,编译器会假设这个名字是一个函数或对象的名字。你需要一个依赖类型的typename关键字,但是如果你在一个非依赖类型之前提供它也没什么坏处。

清单 71-2 展示了几个限定名的例子。

import <chrono>;
import <iostream>;

namespace outer {
   namespace inner {
      class base {
      public:
         int value() const { return 1; }
         static int value(long x) { return static_cast<int>(x); }
      };
   }

   template<class T>
   class derived : public inner::base {
   public:
      typedef T value_type;
      using inner::base::value;
      static value_type example;
      value_type value(value_type i) const { return i * example; }
   };

   template<class T>
   typename derived<T>::value_type derived<T>::example = 2;
}

template<class T>
class more_derived : public outer::derived<T>{
public:
   typedef outer::derived<T> base_type;
   typedef typename base_type::value_type value_type;
   more_derived(value_type v) : value_{this->value(v)} {}
   value_type get_value() const { return value_; }
private:
   value_type value_;
};

int main()

{
   std::chrono::system_clock::time_point now{std::chrono::system_clock::now()};
   std::cout << now.time_since_epoch().count() << '\n';

   outer::derived<int> d;
   std::cout << d.value() << '\n';
   std::cout << d.value(42L) << '\n';
   std::cout << outer::inner::base::value(2) << '\n';

   more_derived<int> md(2);
   std::cout << md.get_value() << '\n';
}

Listing 71-2.Qualified Name Lookup

标准的chrono库使用嵌套的名称空间std::chrono。在这个名称空间中,system_clock类有一个成员 typedef、time_point和一个函数now()

now()函数是静态的,所以它是作为限定名调用的,而不是通过使用成员访问操作符。虽然它对一个对象进行操作,但它的行为就像一个自由函数。now()和一个完全自由的函数的唯一区别是它的名字是由类名而不是名称空间名限定的。探索 41 简要介绍了静态函数。它们不常使用,但这是这种函数有用的实例之一。now()函数是用static限定符声明的,这意味着函数不需要对象,函数体没有this指针,调用函数的通常方式是用限定名。

数据成员也可以是static。成员函数(普通的或静态的)通常可以引用静态数据成员,或者您可以使用限定名从类外部访问该成员。静态成员函数和自由函数的另一个区别是,静态成员函数可以访问类的私有静态成员。如果声明静态数据成员,还必须为该成员提供定义,通常是在定义成员函数的同一源文件中。回想一下,非静态数据成员没有定义,因为数据成员的实例是在创建包含对象时创建的。静态数据成员独立于任何对象,因此它们也必须独立定义。

在清单 71-2 中,对d.value()的第一次调用调用base::value()。没有derived中的using声明,value()的唯一签名是value(value_type i),它与value()不匹配,因此会导致编译错误。但是using inner::base::value声明注入了来自inner::basevalue名称,添加了函数value()和值(long)作为重载名称value的附加函数。因此,当编译器查找d.value()时,它会搜索所有三个签名,以找到using声明注入到derived中的value()。第二个调用d.value(42L),调用value(long)。即使该函数是静态的,也可以使用成员访问操作符来调用它。编译器忽略该对象,但使用该对象的类型作为查找名称的上下文。对value(2)的最后一次调用是由类名限定的,所以它只搜索类base中的value函数,找到value(long),并将int 2转换为long

most_derived类模板中,基类依赖于模板参数T。因此,base_type typedef 是依赖的。编译器需要知道base_type::value_type是什么,所以typename关键字通知编译器value_type是一个类型。

非限定名称查找

没有成员访问操作符或限定符的名字是不合格的。查找非限定名的精确规则取决于上下文。例如,在成员函数内部,编译器先搜索该类的其他成员,然后搜索继承成员,最后搜索该类的命名空间和外部命名空间。

这些规则是常识性的,但是很复杂,而且细节主要适用于编译器的编写者,他们必须确保所有的细节都是正确的。对于大多数程序员来说,你可以通过常识和一些指导方针来应付:

  • 首先在本地范围内查找名称,然后在外部范围内查找。

  • 在一个类中,先在类成员中查找名字,然后在祖先类中查找。

  • 在模板中,编译器必须在定义模板时解析每个非限定对象和类型名,而不考虑实例化上下文。因此,如果基类依赖于模板参数,它不会而不是在基类中搜索名称。

  • 如果在类或祖先中找不到某个名称,或者如果在任何类上下文之外调用该名称,编译器将搜索直接命名空间,然后是外部命名空间。

  • 如果名称是函数或运算符,编译器还会根据参数相关查找(ADL)规则搜索函数参数的命名空间及其外部命名空间。在模板中,搜索模板声明和实例化的名称空间。

清单 71-3 包含了几个非限定名称查找的例子。

import <iostream>;

namespace outer {
   namespace inner {
      struct point { int x, y; };
      inline std::ostream& operator<<(std::ostream& stream, point const& p)
      {
         stream << '(' << p.x << ", " << p.y << ')';
         return stream;
      }
   }
}

typedef int Integer;

int main()
{
   const int multiplier{2};
   for (Integer i : { 1, 2, 3}) {
      outer::inner::point p{ i, i * multiplier };
      std::cout << p << '\n';
   }
}

Listing 71-3.Unqualified Name Lookup

依赖于参数的查找

非限定名称查找最有趣的形式是依赖参数的查找。顾名思义,编译器在由函数参数确定的名称空间中查找函数名。作为一项准则,编译器尽可能地集合最广泛、最具包容性的类和命名空间,以最大化名称的搜索空间。

更准确地说,如果搜索找到一个成员函数,编译器不会应用 ADL,搜索会在那里停止。否则,编译器会汇编一组额外的类和命名空间进行搜索,并将它们与它在普通查找中搜索的命名空间组合在一起。编译器通过检查函数参数的类型来构建这个额外的集合。对于每个函数参数,声明参数类型的类或命名空间被添加到集合中。此外,如果参数的类型是类,则还会添加祖先类及其命名空间。如果参数是指针,则附加的类和命名空间是基类型的类和命名空间。如果您将函数作为参数传递,则该函数的参数类型将被添加到搜索空间中。当编译器搜索附加的 ADL 专用命名空间时,它只搜索匹配的函数名,而忽略类型和变量。

如果函数是模板,附加的类和命名空间包括定义模板和实例化模板的类和命名空间。

清单 71-4 展示了几个参数相关查找的例子。该清单在名称空间numeric中使用了 Exploration 53 中rational的定义。

import <cmath>;
import <iostream>;
import rational;

namespace data {
  template<class T>
  struct point {
    T x, y;
  };
  template<class Ch, class Tr, class T>
  std::basic_ostream<Ch, Tr>& operator<<(std::basic_ostream<Ch, Tr>& stream, point<T> const& pt)
  {
    stream << '(' << pt.x << ", " << pt.y << ')';
    return stream;
  }
  template<class T>
  T abs(point<T> const& pt) {
    using namespace std;
    return sqrt(pt.x * pt.x + pt.y * pt.y);
  }
}

namespace numeric {
   template<class T>
   rational<T> sqrt(rational<T> r)
   {
     using std::sqrt;
     return rational<T>{sqrt(static_cast<double>(r))};
   }
}

int main()
{
   using namespace std;
   data::point<numeric::rational<int>> a{ numeric::rational<int>{1, 2}, numeric::rational<int>{2, 4} };
   std::cout << "abs(" << a << ") = " << abs(a) << '\n';
}

Listing 71-4.Argument-Dependent Name Lookup

main()开始,按照名称查找。

第一个名字是data,查出来是个不合格的名字。编译器找到了在全局名称空间中声明的名称空间data。然后编译器知道在data名称空间中查找point,并找到类模板。类似地,编译器查找numeric,然后查找rational

编译器构造a并将名称添加到局部范围。

编译器先查找std,然后查找cout,因为cout是在<iostream>头文件中声明的。接下来,编译器在局部范围内查找非限定名a。但是之后它还得查abs

编译器首先在局部范围内搜索,然后在全局范围内搜索。using指令告诉编译器也搜索名称空间std。这耗尽了正常查找的可能性,因此编译器必须转向依赖于参数的查找。

编译器集合它要搜索的范围。首先,它将data添加到要搜索的名称空间中。因为point是一个模板,编译器也会搜索模板被实例化的名称空间,也就是全局名称空间。已经搜过了,不过没关系。一旦集合完成,编译器在名称空间data中搜索并找到abs

为了实例化模板abs,使用模板参数numeric::rational<int>,编译器必须查找operator*。它无法在本地范围、名称空间data、名称空间std或全局名称空间中找到声明。使用依赖于参数的查找,它在声明了rationalnumeric名称空间中找到operator*。它对operator+执行相同的查找。

为了找到sqrt,编译器再次使用依赖于参数的查找。当我们上次访问 rational 类时,它缺少一个sqrt函数,所以清单 71-4 提供了一个粗略的函数。它将rational转换为double,调用sqrt,然后再转换回rational。编译器在名称空间std中找到sqrt

最后,编译器必须再次对operator<<应用依赖于参数的查找。当编译器在point中编译operator<<时,它不知道rationaloperator<<,但在模板被实例化之前它不必知道。如您所见,如果您遵循简单的原则,编写利用参数相关查找的代码是很简单的。下一篇文章将进一步研究解析重载函数和操作符的规则。同样,你会发现复杂的规则可以通过遵循一些基本准则变得简单。

七十二、重载的函数和运算符

探索 25 引入了重载函数的概念。探索号 31 带着超载的运算符继续旅程。从那以后,我们设法对重载有了一个常识性的理解。如果我没有更深入地研究这个主题,那将是我的失职,所以让我们通过更深入地研究重载函数和操作符的规则来结束重载的故事。(运算符和函数遵循相同的规则,因此在此探索中,理解函数同样适用于函数和用户定义的运算符。)

类型变换

在跳入重载池的深水区之前,我需要填补一些关于类型转换的缺失部分。回想一下 Exploration 26 中,编译器将某些类型提升为其他类型,比如shortint。它还可以将一种类型(如int)转换为另一种类型(如long)。

将一种类型转换为另一种类型的另一种方法是使用单参数构造器。您可以将rational{1}视为将int文字1转换为rational类型的一种方式。当您声明一个单参数构造器时,您可以告诉编译器您是希望它隐式执行这种类型转换,还是需要显式类型转换。也就是说,如果构造器是隐式的(默认),那么声明其参数类型为rational的函数可以接受一个整数参数,编译器自动从int构造一个rational对象,如下所示:

rational reciprocal(rational const& r)
{
  return rational{r.denominator(), r.numerator()};
}
rational half{ reciprocal(2) };

要禁止这种隐式构造,请在构造器上使用explicit说明符。这迫使用户显式命名该类型,以便调用构造器。例如,std::vector有一个构造器,它将一个整数作为唯一的参数,用默认初始化的元素初始化 vector。构造器是explicit以避免如下语句:

std::vector<int> v;
v = 42;

如果构造器不是explicit,编译器会自动从整数 42 构造一个vector,并将这个vector赋给v。因为构造器是explicit,编译器停止并报告一个错误。

将一种类型转换为另一种类型的另一种方法是使用类型转换运算符。编写这样一个带有关键字operator的操作符,后跟目的地类型。像单参数构造器一样,您可以将类型转换操作符声明为explicit。代替rational中的convertas_float函数,您也可以编写类型转换操作符,如下所示:

explicit operator float() const {
  return float(numerator()) / float(denominator());
}

编译器自动调用类型转换操作符的一个上下文是循环或if-语句条件。因为您在条件中使用表达式,并且条件必须是布尔型的,所以编译器认为这种使用是到类型bool的显式转换。如果你为类型bool实现一个类型转换操作符,总是使用explicit说明符。您将能够在一个条件中测试您的类型的对象,并且您将避免一个令人讨厌的问题,即编译器将您的类型转换为bool,然后将bool提升为int。你不会真的想写,比如:

int i;
i = std::cin; // if conversion to bool were not explicit, i would get 0 or 1

在下面关于重载决策的讨论中,类型转换起着重要的作用。编译器不关心如何将一种类型转换为另一种类型,只关心是否必须执行转换,以及转换是内置在语言中还是用户定义的。构造器相当于类型转换运算符。

重载函数综述

让我们回忆一下。当两个或更多的函数声明在同一个作用域中声明相同的名字时,函数名被重载。C++ 对何时允许重载函数名施加了一些限制。

主要限制是重载函数必须有不同的参数列表。这意味着参数的数量必须不同,或者至少一个参数的类型必须不同。

void print(int value);
void print(double value);         // valid overload: different argument type
void print(int value, int width); // valid overload: different number of arguments

当两个函数仅在返回类型上不同时,不允许在同一范围内定义两个函数。

void print(int);
int print(int);  // illegal

成员函数也可以因有无const限定符而不同。

class demo {
   void print();
   void print() const; // valid: const qualifier is different
};

成员函数不能与同一类中的静态成员函数重载。

class demo {
   void print();
   static void print(); // illegal
};

关键的一点是重载发生在单个作用域内。一个作用域中的名称对另一个作用域中的名称没有影响。记住一个代码块就是一个作用域(Exploration 13 ),一个类就是一个作用域(Exploration 41 ),一个命名空间就是一个作用域(Exploration 56 )。

因此,基类中的成员函数在该类的作用域内,不会影响派生类中的名称重载,派生类有自己的作用域,与基类的作用域分开且不同。

当您在派生类中定义函数时,它会隐藏基类或外部范围中具有相同名称的所有函数,即使这些函数采用不同的参数。该规则是内部作用域中的名称隐藏外部作用域中的名称这一一般规则的一个具体示例。因此,派生类中的任何名称都隐藏了基类和命名空间范围中的名称。块中的任何名称都会隐藏外部块中的名称,依此类推。从派生类中调用隐藏函数的唯一方法是限定函数名,如清单 72-1 所示。

import <iostream>;

class base {
public:
   void print(int x) { std::cout << "int: " << x << '\n'; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << '\n'; }
};
int main()
{
   derived d{};
   d.print(3);           // prints double: 3
   d.print(3.0);         // prints double: 3
   d.base::print(3);     // prints int: 3
   d.base::print(3.0);   // prints int: 3
}

Listing 72-1.Qualifying a Member Function with the Base Class Name

但是,有时您希望重载考虑派生类中的函数以及基类中的函数。解决方案是将基类名称注入派生类范围。使用声明(探索 52 )通过来实现这一点。**修改清单*72-1所以 derived 既可以看到 **的打印功能。*改变main,这样它用一个int参数和一个double参数调用d.print,没有限定名。你期望什么产出?



尝试一下,并将您的结果与清单 72-2 中的结果进行比较。

import <iostream>;

class base {
public:
   void print(int x) { std::cout << "int: " << x << '\n'; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << '\n'; }
   using base::print;
};
int main()
{
   derived d{};
   d.print(3);            // prints int: 3
   d.print(3.0);          // prints double: 3
}

Listing 72-2.Overloading Named with a using Declaration

一个using声明导入了所有同名的重载函数。为了看到这一点, print(long) 添加到基类中,并对 main 进行相应的函数调用。现在你的例子应该看起来类似于清单 72-3 。

import <iostream>;

class base {
public:
   void print(int x) { std::cout << "int: " << x << '\n'; }
   void print(long x) { std::cout << "long: " << x << '\n'; }
};
class derived : public base {
public:
   void print(double x) { std::cout << "double: " << x << '\n'; }
   using base::print;
};
int main()
{
   derived d{};
   d.print(3);           // prints int: 3
   d.print(3.0);         // prints double: 3
   d.print(3L);          // prints long: 3
}

Listing 72-3.Adding a Base Class Overload

过载规则通常运行良好。你可以清楚地看到,对于main中的每个函数调用,编译器选择了哪个print函数。然而,有时规则变得更加模糊。

例如,假设您要将行d.print(3.0f);添加到main你希望程序打印出什么?


编译器将float 3.0f提升为类型double并调用print(double),因此输出如下:

double: 3

那太容易了。来个short怎么样?试试 d.print(short(3)) 。会发生什么?


编译器将short提升为类型int,并产生以下输出:

int: 3

这还是太容易了。现在试试unsigned d.print(3u)会发生什么?


那根本不管用,是吗?错误消息可能说了一些关于不明确的重载或函数调用的事情。要理解哪里出了问题,您需要更好地理解重载在 C++ 中是如何工作的,这也是本文余下部分的全部内容。

霸王决议

编译器应用其正常的查找规则(Exploration 71 )来查找函数名的声明。当编译器找到所需名称的第一个匹配项时,它将停止搜索,但是该范围可能有多个同名的声明。对于类型或变量,这将是一个错误,但函数可能有多个或重载的同名声明。

在编译器为它正在查找的函数名找到一个声明后,它会在同一作用域中找到该名称的所有函数声明,并应用其重载规则来选择它认为最匹配函数参数的一个声明。这个过程叫做解析重载的名字。

为了解决重载问题,编译器会考虑参数及其类型、函数声明中函数参数的类型,以及转换参数类型以匹配参数类型所需的类型转换和提升。像姓名查找一样,详细的规则是复杂的,微妙的,有时令人惊讶。但是如果你避免编写病态重载,你通常可以通过一些常识性的指导方针。

编译器找到函数名声明后,重载决策开始。编译器收集同一范围内同名的所有声明。这意味着编译器不包含来自任何基类或祖先类的同名函数。一个using声明可以将这样的名字带入派生类范围,从而让它们参与重载解析。如果函数名是非限定的,编译器会寻找成员函数和非成员函数。另一方面,using指令对重载解析没有影响,因为它不改变名称空间中的任何名称。

如果函数是构造器,并且有一个参数,编译器还会考虑返回所需类或派生类的类型转换运算符。

然后,编译器会丢弃任何参数个数错误的函数,或者那些函数参数无法转换为相应的参数类型的函数。它检查约束,并且不考虑任何约束检查失败的函数。当匹配成员函数时,编译器会添加一个隐式参数,这是一个指向对象的指针,就像this是一个函数参数一样。

最后,编译器通过测量将每个实参转换为相应的形参类型需要做什么来对所有剩余的函数进行排序,这将在下一节中解释。如果有一个具有最佳排名的唯一获胜者,则编译器已经成功地解决了重载。如果没有,编译器会应用一些平局决胜规则来尝试选择排名最好的函数。如果编译器不能选出一个获胜者,它将报告一个模糊错误。如果它有一个获胜者,它将继续下一个编译步骤,即检查成员函数的访问级别。排名最佳的重载可能不可访问,但这并不影响编译器解决重载的方式。

排名功能

为了对函数进行排序,编译器确定如何将每个参数转换为相应的参数类型。执行摘要是,排名最好的函数是需要最少的工作来将所有参数转换为所需的参数类型的函数。

编译器有几个工具可以将一种类型转换成另一种类型。其中许多你已经在本书前面看到过,比如提升算术类型(探索 26 ),将派生类引用转换为基类引用(探索 37 ),或者调用类型转换操作符。编译器将一系列转换组合成一个隐式转换序列 (ICS)。ICS 是一系列小的转换步骤,编译器可以将这些步骤应用于函数调用参数,最终结果是将参数转换为相应函数参数的类型。

编译器有排序规则来决定一个 ICS 是否比另一个更好。编译器试图找到一个函数,对于该函数,每个参数的 ICS 是所有重载名称中最好的(或并列最好的)ICS,并且至少有一个 ICS 无疑是最好的。如果是这样的话,它会选择排名最高的函数。否则,如果它有一组函数都与最佳 ICSes 集相匹配,它将进入加时赛,如下一节所述。本节的剩余部分将讨论编译器如何对 ICSes 进行排序。

首先,一些术语。ICS 可能涉及标准转换或用户定义的转换。标准转换是 C++ 语言所固有的,比如算术转换。一个用户定义的转换涉及类和枚举类型上的构造器和类型转换操作符。标准 IC是只包含标准转换的 IC。一个用户定义的 IC由一系列标准转换组成,序列中任何地方都有一个用户定义的转换。(因此,任何需要两次用户派生转换才能将实参转换为形参类型的重载都不会达到这一步,因为编译器无法将实参转换为形参类型,因此不再考虑该函数签名。)

例如,将short转换为const int是一个标准的 ICS,有两个步骤:将short提升为int并添加const限定符。将字符串文字转换为std::string是一个用户定义的 ICS,它包含一个标准转换(将const char的数组转换为指向const char的指针),后面是一个用户定义的转换(std::string构造器)。

一个例外是,调用复制构造器将相同的源和目标类型或派生类源复制到基类类型是标准转换,而不是用户定义的转换,即使这些转换调用用户定义的复制构造器。

编译器必须选择仍在考虑中的函数的最佳集成电路。作为这一决定的一部分,它必须能够在一个 ICS 中比较标准转换。标准转换分为三类。按照从最好到最差的顺序,类别是精确匹配、推广和其他转换。

精确匹配是指实参类型与形参类型相同。精确匹配转换的示例如下:

  • 只改变限定条件,例如,自变量是类型int,参数是const int(但不是指向const的指针或对const的引用)

  • 将数组转换为指针(探索 59 ,例如char[10]char*

  • 将左值转换为右值,例如,int&int

一个提升(探索 26 )是从较小的算术类型(如short)到较大类型(如int)的隐式转换。编译器认为提升比转换更好,因为提升不会丢失任何信息,但转换可能会。

所有其他隐式类型转换—例如,丢弃信息的算术转换(如longint)和基类指针的派生类指针—属于杂项转换的最后一类。

序列的类别是序列中最差转换步骤的类别。例如,将short转换为const int涉及一个精确匹配 ( const)和一个提升 ( shortint),因此 ICS 作为一个整体的类别是提升

如果一个参数是隐式对象参数(用于成员函数调用),编译器也会比较它所需的任何转换。

现在您知道了编译器如何按类别对标准转换进行排序,您可以看到它如何使用这些信息来比较 ICSes。编译器应用以下规则来确定两个 ICSes 中哪一个更好:

  • 标准 ICS 比用户定义的 ICS 更好。

  • 类别更好的 ICS 比类别更差的 ICS 更好。

  • 作为另一个 ICS 的真子集的 ICS 更好。

  • 如果一个用户定义的 ICS1 比另一个用户定义的 ICS2 具有相同的用户转换,并且 ICS1 中的第二个标准转换优于 ICS2 中的第二个标准转换,则 ICS 1 优于 ICS 2。

  • 限制较少的类型比限制较多的类型更好。这意味着目标类型为 ?? 的 ICS 比目标类型为 ?? 的 ICS 更好,如果 ?? 和 ?? 具有相同的基本类型,但 ?? 是 const 而 ?? 不是。

  • 标准转换序列 ICS1 比 ICS2 更好,如果它们具有相同的等级,但是

    • ICS1 将指针转换为bool

    • ICS1 和 ICS2 将指针转换为通过继承相关的类,ICS1 是一个“更小”的转换。较小的转换是跳过较少中间基类的转换。举个例子,如果A是从B派生出来的,B是从C派生出来的,那么把B*转换成C*比把A*转换成C*好,把C*转换成void*比把A*转换成void*好。

列表初始化

一个复杂的情况是函数参数可能没有类型,因为它不是表达式。相反,参数是一个用花括号括起来的值列表,例如用于通用初始化的花括号括起来的列表。编译器有一些特殊的规则来决定一个列表的转换顺序。

如果参数类型是一个具有构造器的类,该构造器采用类型为std::initializer_list<T>的单个参数,并且大括号括起来的列表中的每个成员都可以转换为T,编译器会将该参数视为用户定义的到std::initializer_list<T>的转换。例如,所有的容器类都有这样的构造器。

否则,编译器会尝试为该参数类型找到一个构造器,使得大括号括起来的列表中的每个元素都是该构造器的一个参数。如果成功,编译器认为该列表是用户定义的到参数类型的转换。请注意,每个构造器参数都允许另一个用户定义的转换序列。

编译器认为std::initializer_list初始化比其他构造器列表初始化更好。这就是为什么std::string{42, 'x'}不调用std::string(42, 'x')构造器的原因:编译器更喜欢将{42, 'x'}视为std::initializer_list,这将产生一个包含两个字符的字符串,一个包含代码点 42 和字母 x ,而不是创建包含 42 个重复字母 x 的字符串的构造器。

如果参数类型不是类,并且大括号括起来的列表包含单个元素,则编译器会从大括号中解开值,并应用由括起来的值产生的普通 ICS。

决胜局

如果编译器找不到一个比其他函数排名更高的函数,它会应用一些最终规则来选择一个胜出者。编译器按顺序检查下列规则。如果一个规则产生一个获胜者,编译器就在那个点停止,并使用获胜的函数。否则,它将继续下一个加时赛:

  • 尽管返回类型不被视为重载决策的一部分,但如果重载的函数调用用于用户定义的初始化,则调用更好的标准转换序列的函数返回类型将胜出。

  • 非模板函数胜过函数模板。

  • 更特化的函数模板胜过不那么特化的函数模板。(引用或指针模板参数比非引用或非指针参数更加特化。一个const参数比非const参数更加特化。)

  • 否则,编译器会报告一个模糊错误。

清单 72-4 展示了一些重载的例子以及 C++ 如何对函数进行排序。

import <iostream>;
import <string>;

void print(std::string_view str) { std::cout << str; }
void print(int x)                { std::cout << "int: " << x; }
void print(double x)             { std::cout << "double: " << x; }

class base {
public:
  void print(std::string_view str) const { ::print(str); ::print("\n"); }
  void print(std::string_view s1, std::string_view s2)
  {
    print(s1); print(s2);
  }
};

class convert : public base {
public:
  convert()              { print("convert()"); }
  convert(double)        { print("convert(double)"); }
  operator int() const   { print("convert::operator int()"); return 42; }
  operator float() const { print("convert::operator float()"); return 3.14159f; }
};

class demo : public base {
public:
  demo(int)      { print("demo(int)"); }
  demo(long)     { print("demo(long)"); }
  demo(convert)  { print("demo(convert)"); }
  demo(int, int) { print("demo(int, int)"); }
};

class other {
public:
  other()        { std::cout << "other::other()\n"; }
  other(int,int) { std::cout << "other::other(int, int)\n"; }
  operator convert() const
  {
    std::cout << "other::operator convert()\n"; return convert();
  }
};

int operator+(demo const&, demo const&)
{
  print("operator+(demo,demo)\n"); return 42;
}

int operator+(int, demo const&) { print("operator+(int,demo)\n"); return 42; }

int main()

{
  other x{};
  demo d{x};
  3L + d;
  short s{2};
  d + s;
}

Listing 72-4.Ranking Functions for Overload Resolution

你期望清单 69-4 中的程序输出什么?





大多数时候,常识性的规则可以帮助你理解 C++ 是如何解决重载的。然而,有时您会发现编译器报告了一个您并不期望的歧义。其他时候,当您期望编译器成功时,它却无法解析重载。真正糟糕的情况是,当你犯了一个错误,编译器能够找到一个独特的函数,但这个函数与你期望的不同。您的测试失败了,但是在读取代码时,您找错了地方,因为您期望编译器抱怨糟糕的代码。

有时,你的编译器会帮助你识别那些排名最好的函数。然而,有时你可能不得不坐下来仔细检查规则,找出编译器不满意的原因。为了帮助你为那天做好准备,清单 72-5 给出了一些重载错误。看看你能否找到并解决问题。

import <iostream>;
import <string>;

void easy(long) {}
void easy(double) {}
void call_easy() {
   easy(42);
}

void pointer(double*) {}
void pointer(void*) {}
const int zero = 0;
void call_pointer() {
   pointer(&zero);
}

int add(int a) { return a; }
int add(int a, int b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
int add(int a, int b, int c, int d) { return a + b + c + d; }
int add(int a, int b, int c, int d, int e) { return a + b + c + d + e; }
void call_add() {
   add(1, 2, 3L, 4.0);
}

void ref(int const&) {}
void ref(int) {}
void call_ref() {
   int x;
   ref(x);
}

class base {};
class derived : public base {};
class sibling : public base {};
class most_derived : public derived {};

void tree(derived&, sibling&) {}
void tree(most_derived&, base&) {}
void call_tree() {
   sibling s;
   most_derived md;
   tree(md, s);
}

Listing 72-5.Fix the Overloading Errors

easy()的参数是一个int,但是重载是针对longdouble的。这两种转换都有转换秩,并且没有一个比另一个更好,因此编译器会发出一个模糊错误。

pointer()的问题是这两种超载都不可行。如果zero不是const,转换成void*将是唯一可行的选择。

add()函数有所有的int参数,但是一个参数是long,另一个是double。没问题,编译器可以把long转换成int,把double转换成int。你可能不喜欢这个结果,但是它能够做到,所以它做到了。换句话说,这里的问题是编译器没有这个函数的问题。这不是一个真正的超载问题,但是如果你在工作中遇到这个问题,你可能不会这么看。

你看到第二个ref()函数中缺少的&了吗?编译器认为两个ref()函数一样好。如果你声明第二个是ref(int&),它就成为最佳可行候选。确切原因是x的类型是int&,不是int,也就是说x是一个int左值,一个程序可以修改的对象。这种微妙的区别以前并不重要,但是对于重载,这种区别是至关重要的。从左值到右值的转换具有等级精确匹配,但这是一个转换步骤。从int&int const&的转换也完全匹配。面对两个各有一个精确匹配转换的候选者,编译器无法决定哪一个更好。将int更改为int&取消了转换步骤,该功能成为明确的最佳功能。

两个tree()函数都需要一次从派生类引用到基类引用的转换,所以编译器无法决定哪一个更好。对tree的第一次调用需要将第一个参数从most_derived&转换为derived&。第二个调用需要将第二个参数从sibling&转换为base&

请记住,重载的目的是允许跨多种类型的单个逻辑操作,或者允许以多种方式调用单个逻辑操作(如构造字符串)。当你决定重载一个函数时,这些规则将帮助你做出正确的选择。

Tip

当您编写重载函数时,您应该确保特定函数名的每个实现都具有相同的逻辑行为。例如,当您使用一个输出操作符cout << x时,您只需让编译器为operator<<选择正确的重载,您不必关心本研究中列出的详细规则。所有的规则都适用,但是标准声明了一组合理的重载,这些重载与内置类型和关键库类型一起工作,比如std::string

默认参数

既然你认为重载是如此的复杂,以至于你永远也不想重载一个函数,我将增加另一个复杂性。C++ 让你为一个参数定义一个默认的参数,这让一个函数调用省略相应的参数。您可以为任意数量的参数定义默认参数,只要您省略最右边的参数并且不跳过任何参数。如果愿意,您可以为每个参数提供默认参数。默认参数通常很容易理解。阅读清单 72-6 中的示例。

import <iostream>;

int add(int x = 0, int y = 0)
{
  return x + y;
}

int main()
{
  std::cout << add() << '\n';
  std::cout << add(5) << '\n';
  std::cout << add(32, add(4, add(6))) << '\n';
}

Listing 72-6.Default Arguments

清单 72-6 中的程序打印什么?




不难预测结果,如下图所示:

0
5
42

默认参数提供了替代重载的捷径。例如,您可以使用一个构造器和默认参数,而不是为rational类型编写几个构造器,如下所示:

template<class T> class rational {
public:
  rational(T const& num = T{0}, T const& den = T{1})
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  ...omitted for brevity...
};

我们对默认构造器的定义必须有所改变。默认构造器不是不声明参数的构造器,而是可以不带参数调用的构造器。这个rational构造器符合这个要求。

正如您可能已经猜到的,默认参数使重载决策变得复杂。当编译器搜索重载函数时,它会检查函数调用中显式出现的每个参数,但不会根据默认的参数类型来检查默认的参数类型。因此,使用默认参数会更容易陷入不明确的情况。例如,假设您将示例rational构造器添加到现有的类模板中,而没有删除旧的构造器。以下定义都会导致模糊错误:

rational<int> zero{};
rational<int> one{1};

默认参数有其用途,但重载通常会给你更多的控制权。例如,通过重载 rational 构造器,当我们知道分母是 1 时,我们避免调用reduce()。使用内联函数,一个重载函数可以调用另一个重载函数,这通常完全消除了对默认参数的需要。如果你不确定是使用默认参数还是重载,我推荐重载。

虽然你可能不相信我,但我的意图并不是吓你不要重载函数。你很少会去探究超载的微妙之处。大多数时候,你可以依靠常识。但是有时候,编译器和你的常识不一致。当编译器抱怨不明确的重载或其他问题时,了解编译器的规则可以帮助您摆脱困境。

下一篇文章探讨了 C++ 编程的另一个方面,它的规则可能复杂而可怕:元编程,或者编写在编译时运行的程序。