0%

C++ Primer - 第 14 章 操作重载与类型转换

  1. 和内置类型的转换一样,类类型转换隐式地将一种类型的对象转换成另一种我们所需类型的对象。

  2. 当运算符作用于类类型的运算对象时,可以通过运算符重载重新定义该运算符的含义。

14.1 基本概念

  1. 重载的运算符是具有特殊名字的函数:它们的名字由关键字 operator 和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。

    重载运算符函数的参数数量与该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符 operator() 之外,其他重载运算符不能含有默认实参。

    如果一个运算符函数是成员函数,则它的第一个(左侧)运算对象绑定到隐式的 this 指针上,因此,成员运算符函数的(显式)参数数量比运算符的运算对象总数少一个。

    Note:当一个重载的运算符是成员函数时,this 绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。

  2. 对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。这一约定意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义。

  3. 我们可以重载大多数(但不是全部)运算符:

    运算符
    可以被重载的运算符
    + - * / % ^
    & | ~ ! , =
    < > <= >= ++ --
    << >> == != && ||
    += -= /= %= ^= &=
    |= *= <<= >>= [] ()
    -> ->* new new[] delete delete[]
    不能被重载的运算符
    :: .* . ?:
  4. 我们只能重载已有的运算符,而无权发明新的运算符号。

  5. 有四个符号(+-*&)既是一元运算符也是二元运算符,所有这些运算符都能被重载,从参数的数量我们可以推断到底定义的是哪种运算符。

  6. 对于一个重载的运算符来说,其优先级和结合律与对应的内置运算符保持一致。 不考虑运算对象类型的话,

    1
    x == y + z;

    永远等价于 x == (y + z);

  7. 通常情况下,我们将运算符作用于类型正确的实参,从而以这种间接方式“调用”重载的运算符函数。然而,我们也能像调用普通函数一样直接调用运算符函数,先指定函数名字,然后传入数量正确、类型适当的实参

    1
    2
    3
    // 一个非成员运算符函数的等价调用
    data1 + data2; // 普通的表达式
    operator+(data1, data2); // 等价的函数调用

    我们像调用其他成员函数一样显式地调用成员运算符函数。具体做法是,首先指定运行函数的对象(或指针)的名字,然后使用点运算符(或箭头运算符)访问希望调用的函数

    1
    2
    data1 += data2;          // 基于“调用”的表达式
    data1.operator+=(data2); // 对成员运算符函数的等价调用
  8. 因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。特别是,逻辑与运算符、逻辑或运算符和逗号运算符的运算对象求值顺序规则无法保留下来。除此之外,&&|| 运算符的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值

    因为上述运算符的重载版本无法保留求值顺序和/或短路求值属性,因此不建议重载它们。当代码使用了这些运算符的重载版本时,用户可能会突然发现他们一直习惯的求值规则不再适用了。

  9. 还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++ 语言已经定义了这两种运算符用于类类型对象时的特殊含义,这一点与大多数运算符都不相同。因为这两种运算符已经有了内置的含义,所以一般来说它们不应该被重载,否则它们的行为将异于常态,从而导致类的用户无法适应。

    Best Practices:通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

  10. 赋值运算符的行为与复合版本的类似:赋值之后,左侧运算对象和右侧运算对象的值相等,并且运算符应该返回它左侧运算对象的一个引用。重载的赋值运算应该继承而非违背其内置版本的含义。

  11. 下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:

    • 赋值(=)、下标([])、调用(())和成员访问箭头(->)运算符必须是成员。
    • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
    • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
    • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。
  12. 如果我们想提供含有类对象的混合类型表达式,则运算符必须定义成非成员函数。当我们把运算符定义成成员函数时,它的左侧运算对象必须是运算符所属类的一个对象。 例如:

    1
    2
    3
    string s = "world";
    string t = s + "!"; // 正确:我们能把一个 const char* 加到一个 string 对象中
    string u = "hi" + s; // 如果 + 是 string 的成员,则产生错误

    如果 operator+string 类的成员,则上面的第一个加法等价于 s.operator+("!")。同样的,"hi" + s 等价于 "hi".operator+(s)。显然 "hi" 的类型是 const char*,这是一种内置类型,根本就没有成员函数。

    因为 string+ 定义成了普通的非成员函数,所以 "hi" + s 等价于 operator+("hi", s)。和任何其他函数调用一样,每个实参都能被转换成形参类型。唯一的要求是至少有一个运算对象是类类型,并且两个运算对象都能准确无误地转换成 string

14.2 输入和输出运算符

14.2.1 重载输出运算符 <<

  1. 通常情况下,输出运算符的第一个形参是一个非常量 ostream 对象的引用。之所以 ostream 是非常量是因为向流写入内容会改变其状态;而该形参是引用是因为我们无法直接复制一个 ostream 对象。第二个形参一般来说是一个常量的引用,该常量是我们想要打印的类类型。第二个形参是引用的原因是我们希望避免复制实参;而之所以该形参可以是常量是因为(通常情况下)打印对象不会改变对象的内容。为了与其他输出运算符保持一致,operator<< 一般要返回它的 ostream 形参。

  2. Best Practices: 通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。

  3. iostream 标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。 否则,它们的左侧运算对象将是我们的类的一个对象。

  4. IO 运算符通常需要读写类的非公有数据成员,所以 IO 运算符一般被声明为友元。

14.2.2 重载输入运算符 >>

  1. 通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。

  2. Note: 输入运算符必须处理输入可能失败的情况,而输出运算符不需要。

  3. Best Practices: 当读取操作发生错误时,输入运算符应该负责从错误中恢复。

14.3 算术和关系运算符

  1. 通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。

  2. 算术运算符通常会计算它的两个运算对象并得到一个新值,这个值有别于任意一个运算对象,常常位于一个局部变量之内,操作完成后返回该局部变量的副本作为其结果。 如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:

    1
    2
    3
    4
    5
    6
    7
    // 假设两个对象指向同一本书
    Sales_data operator+(const Sales_data &lhs, const Sales_data &rhs)
    {
    Sales_data sum = lhs; // 把 lhs 的数据成员拷贝给 sum
    sum += rhs; // 将 rhs 加到 sum 中
    return sum;
    }

    Tip: 如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符。

14.3.1 相等运算符

    • 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成 operator== 而非一个普通的命名函数:因为用户肯定希望能使用 == 比较对象,所以提供了 == 就意味着用户无须再费时费力地学习并记忆一个全新的函数名字。此外,类定义了 == 运算符之后也更容易使用标准库容器和算法。
    • 如果类定义了 operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
    • 通常情况下,相等运算符应该具有传递性,换句话说,如果 a == bb == c 都为真,则 a == c 也应该为真。
    • 如果类定义了 operator==,则这个类也应该定义 operator!=。对于用户来说,当他们能使用 == 时肯定也希望能使用 !=,反之亦然。
    • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。


    Best Practices: 如果某个类在逻辑上有相等性的含义,则该类应该定义 operator==,这样做可以使得用户更容易使用标准库算法来处理这个类。

14.3.2 关系运算符

  1. 定义了相等运算符的类也常常(但不总是)包含关系运算符。特别是,因为关联容器和一些算法要用到小于运算符,所以定义 operator< 会比较有用。
  2. Best Practices: 如果存在唯一一种逻辑可靠的 < 定义,则应该考虑为这个类定义 < 运算符。如果类同时还包含 ==,则当且仅当 < 的定义和 == 产生的结果一致时才定义 < 运算符。

14.4 赋值运算符

  1. 和拷贝赋值及移动赋值运算符一样,其他重载的赋值运算符也必须先释放当前内存空间,再创建一片新空间。(移动赋值运算符并没有创建新空间,而是接管了右侧运算对象的资源——博主注)

  2. 我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。

  3. 复合赋值运算符不非得是类的成员,不过我们还是倾向于把包括复合赋值在内的所有赋值运算都定义在类的内部。为了与内置类型的复合赋值保持一致,类中的复合赋值运算符也要返回其左侧运算对象的引用。

    Best Practices:赋值运算符必须定义成类的成员,复合赋值运算符通常情况下也应该这样做。这两类运算符都应该返回左侧运算对象的引用。

14.5 下标运算符

  1. Note:下标运算符必须是成员函数。

  2. 为了与下标的原始定义兼容,下标运算符通常以所访问元素的引用作为返回值,这样做的好处是下标可以出现在赋值运算符的任意一端。进一步,我们最好同时定义下标运算符的常量版本和非常量版本,当作用于一个常量对象时,下标运算符返回常量引用以确保我们不会给返回的对象赋值

    Best Practices:如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

    举个例子,我们按照如下形式定义 StrVec 的下标运算符:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class StrVec
    {
    public:
    std::string &operator[](std::size_t n)
    {
    return elements[n];
    }
    const std::string &operator[](std::size_t n) const
    {
    return elements[n];
    }

    private:
    std::string *elements; // 指向数组首元素的指针
    };

    上面这两个下标运算符的用法类似于 vector 或者数组中的下标。因为下标运算符返回的是元素的引用,所以当 StrVec 是非常量时,我们可以给元素赋值;而当我们对常量对象取下标时,不能为其赋值:

    1
    2
    3
    4
    5
    6
    7
    8
    // 假设 svec 是一个 StrVec 对象
    const StrVec cvec = svec; // 把 svec 的元素拷贝到 cvec 中
    // 如果 svec 中含有元素,对第一个元素运行 string 的 empty 函数
    if (svec.size() && svec[0].empty())
    {
    svec[0] = "zero"; // 正确:下标运算符返回 string 的引用
    cvec[0] = "Zip"; // 错误:对 cvec 取下标返回的是常量引用
    }

    通过区分成员函数是否是 const 的,我们可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用 const 成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。——博主注,《C++ Primer - 第 7 章 类》第 41 条。

14.6 递增和递减运算符

  1. C++ 语言并不要求递增和递减运算符必须是类的成员,但是因为它们改变的正好是所操作对象的状态,所以建议将其设定为成员函数。
  2. Best Practices:定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。

  3. Best Practices:为了与内置版本保持一致,前置运算符应该返回递增或递减后对象的引用。

  4. 要想同时定义前置和后置运算符,必须首先解决一个问题,即普通的重载形式无法区分这两种情况。前置和后置版本使用的是同一个符号,意味着其重载版本所用的名字将是相同的,并且运算对象的数量和类型也相同。

    为了解决这个问题,后置版本接受一个额外的(不被使用)int 类型的形参。当我们使用后置运算符时,编译器为这个形参提供一个值为 0 的实参。尽管从语法上来说后置函数可以使用这个额外的形参,但是在实际过程中通常不会这么做。这个形参的唯一作用就是区分前置版本和后置版本的函数,而不是真的要在实现后置版本时参与运算。

  5. Best Practices:为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。

  6. 对于后置版本来说,在递增对象之前需要首先记录对象的状态:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 后置版本:递增/递减对象的值但是返回原值
    StrBlobPtr StrBlobPtr::operator++(int)
    {
    // 此处无须检查有效性,调用前置递增运算时才需要检查
    StrBlobPtr ret = *this; // 记录当前的值
    ++*this; // 向前移动一个元素,前置 ++ 需要检查递增的有效性
    return ret; // 返回之前记录的状态
    }
    StrBlobPtr StrBlobPtr::operator--(int)
    {
    // 此处无须检查有效性,调用前置递减运算时才需要检查
    StrBlobPtr ret = *this; // 记录当前的值
    --*this; // 向后移动一个元素,前置 -- 需要检查递减的有效性
    return ret; // 返回之前记录的状态
    }

    Note: 因为我们不会用到 int 形参,所以无须为其命名。

  7. 如果我们想通过函数调用的方式调用后置版本,则必须为它的整型参数传递一个值:

    1
    2
    3
    StrBlobPtr p(a1); // p 指向 a1 中的 vector
    p.operator++(0); // 调用后置版本的 operator++
    p.operator++(); // 调用前置版本的 operator++

    尽管传入的值通常会被运算符函数忽略,但却必不可少,因为编译器只有通过它才能知道应该使用后置版本。

14.7 成员访问运算符

  1. Note: 箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。

  2. Note: 重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

14.8 函数调用运算符

  1. 如果类重载了函数调用运算符,则我们可以像使用函数一样使用该类的对象。

    举个简单的例子,下面这个名为 absIntstruct 含有一个调用运算符,该运算符负责返回其参数的绝对值:

    1
    2
    3
    4
    struct absInt
    {
    int operator()(int val) const { return val < 0 ? -val : val; };
    };

    这个类只定义了一种操作:函数调用运算符,它负责接受一个 int 类型的实参,然后返回该实参的绝对值。

    我们使用调用运算符的方式是令一个 absInt 对象作用于一个实参列表,这一过程看起来非常像调用函数的过程:

    1
    2
    3
    int i = -42;
    absInt absObj; // 含有函数调用运算符的对象
    int ui = absObj(i); // 将 i 传递给 absObj.operator()

    即使 absObj 只是一个对象而非函数,我们也能“调用”该对象。调用对象实际上是在运行重载的调用运算符。 在此例中,该运算符接受一个 int 值并返回其绝对值。

  2. Note:函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

  3. 如果类定义了调用运算符,则该类的对象称作函数对象(function object)。因为可以调用这种对象,所以我们说这些对象的“行为像函数一样”。

  4. 和其他类一样,函数对象类除了 operator() 之外也可以包含其他成员。函数对象类通常含有一些数据成员,这些成员被用于定制调用运算符中的操作。

  5. 函数对象常常作为泛型算法的实参。 例如,可以使用标准库 for_each 算法和我们自己的 PrintString 类来打印容器的内容:

    1
    for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));

14.8.1 lambda 是函数对象

  1. 当我们编写了一个 lambda 后,编译器将该表达式翻译成一个未命名类的未命名对象,在 lambda 表达式产生的类中含有一个重载的函数调用运算符,例如,对于我们传递给 stable_sort 作为其最后一个实参的 lambda 表达式来说:

    1
    2
    3
    4
    // 根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序
    stable_sort(words.begin(), words.end(),
    [](const string &a, const string &b)
    { return a.size() < b.size(); });

    其行为类似于下面这个类的一个未命名对象

    1
    2
    3
    4
    5
    6
    7
    8
    class ShorterString
    {
    public:
    bool operator()(const string &s1, const string &s2) const
    {
    return s1.size() < s2.size();
    }
    };

    产生的类只有一个函数调用运算符成员,它负责接受两个 string 并比较它们的长度,它的形参列表和函数体与 lambda 表达式完全一样。默认情况下 lambda 不能改变它捕获的变量。因此在默认情况下,由 lambda 产生的类当中的函数调用运算符是一个 const 成员函数。如果 lambda 被声明为可变的,则调用运算符就不是 const 的了。

    用这个类替代 lambda 表达式后,我们可以重写并重新调用 stable_sort

    1
    stable_sort(words.begin(), words.end(), ShorterString());

    第三个实参是新构建的 ShorterString 对象,当 stable_sort 内部的代码每次比较两个 string 时就会“调用”这一对象,此时该对象将调用运算符的函数体,判断第一个 string 的大小小于第二个时返回 true

  2. 当一个 lambda 表达式通过引用捕获变量时,将由程序负责确保 lambda 执行时引用所引的对象确实存在。因此,编译器可以直接使用该引用而无须在 lambda 产生的类中将其存储为数据成员。

  3. 通过值捕获的变量被拷贝到 lambda 中。因此,这种 lambda 产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量的值来初始化数据成员。

  4. lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数;它是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。

14.8.2 标准库定义的函数对象

  1. 标准库定义了一组表示算术运算符、关系运算符和逻辑运算符的类,每个类分别定义了一个执行命名操作的调用运算符。例如,plus 类定义了一个函数调用运算符用于对一对运算对象执行 + 的操作;modulus 类定义了一个调用运算符执行二元的 % 操作;equal_to 类执行 ==,等等。

    这些类都被定义成模板的形式,我们可以为其指定具体的应用类型,这里的类型即调用运算符的形参类型。例如,plus<string>string 加法运算符作用于 string 对象;plus<int> 的运算对象是 intplus<Sales_data>Sales_data 对象执行加法运算,以此类推。

    标准库函数对象
    算术 关系 逻辑
    plus<Type> equal_to<Type> logical_and<Type>
    minus<Type> not_equal_to<Type> logical_or<Type>
    multiplies<Type> greater<Type> logical_not<Type>
    divides<Type> greater_equal<Type>
    modulus<Type> less<Type>
    negate<Type> less_equal<Type>
  2. 表示运算符的函数对象类常用来替换算法中的默认运算符。如我们所知,在默认情况下排序算法使用 operator< 将序列按照升序排列。如果要执行降序排列的话,我们可以传入一个 greater 类型的对象。该类将产生一个调用运算符并负责执行待排序类型的大于运算。例如,如果 svec 是一个 vector<string>

    1
    sort(svec.begin(), svec.end(), greater<string>());

    则上面的语句将按照降序对 svec 进行排序。第三个实参是 greater<string> 类型的一个未命名的对象,因此当 sort 比较元素时,不再是使用默认的 < 运算符,而是调用给定的 greater 函数对象。该对象负责在 string 元素之间执行 > 比较运算。

    需要特别注意的是,标准库规定其函数对象对于指针同样适用。我们之前曾经介绍过比较两个无关指针将产生未定义的行为,然而我们可能会希望通过比较指针的内存地址来 sort 指针的 vector。直接这么做将产生未定义的行为,因此我们可以使用一个标准库函数对象来实现该目的:

    1
    2
    3
    4
    5
    6
    7
    vector<string *> nameTable; // 指针的 vector
    //错误:nameTable 中的指针彼此之间没有关系,所以 < 将产生未定义的行为
    sort(nameTable.begin(), nameTable.end(),
    [](string *a, string *b)
    { return a < b; });
    // 正确:标准库规定指针的 less 是定义良好的
    sort(nameTable.begin(), nameTable.end(), less<string *>());

    关联容器使用 less<key_type> 对元素排序,因此我们可以定义一个指针的 set 或者在 map 中使用指针作为关键值而无须直接声明 less

14.8.3 可调用对象与 function

  1. C++ 语言中有几种可调用的对象:函数、函数指针、lambda 表达式、bind 创建的对象以及重载了函数调用运算符的类。

  2. 和其他对象一样,可调用的对象也有类型。例如,每个 lambda 有它自己唯一的(未命名)类类型;函数及函数指针的类型则由其返回值类型和实参类型决定,等等。

    然而,两个不同类型的可调用对象却可能共享同一种调用形式(call signature)。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型,例如:

    1
    int(int, int)

    是一个函数类型,它接受两个 int、返回一个 int

  3. 对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。例如,考虑下列不同类型的可调用对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 普通函数
    int add(int i, int j) { returni + j; }
    // lambda,其产生一个未命名的函数对象类
    auto mod = [](int i, int j)
    { return i % j; };
    // 函数对象类
    struct divide
    {
    int operator()(int denominator, int divisor)
    {
    return denominator / divisor;
    }
    };

    上面这些可调用对象分别对其参数执行了不同的算术运算,尽管它们的类型各不相同,但是共享同一种调用形式:

    1
    int(int, int)
  4. 函数表(function table)用于存储指向可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。

    在 C++ 语言中,函数表很容易通过 map 来实现。对于此例来说,我们使用一个表示运算符符号的 string 对象作为关键字;使用实现运算符的函数作为值。当我们需要求给定运算符的值时,先通过运算符索引 map,然后调用找到的那个元素。

    假定我们的所有函数都相互独立,并且只处理关于 int 的二元运算,则 map 可以定义成如下的形式:

    1
    2
    // 构建从运算符到函数指针的映射关系,其中函数接受两个 int、返回一个 int
    map<string, int (*)(int, int)> binops;

    我们可以按照下面的形式将 add 的指针添加到 binops 中:

    1
    2
    // 正确:add 是一个指向正确类型函数的指针
    binops.insert({"+", add}); // {"+", add} 是一个 pair

    但是我们不能将 mod 或者 divide 存入 binops

    1
    binops.insert({"%", mod}); // 错误:mod 不是一个函数指针

    问题在于 mod 是个 lambda 表达式,而每个 lambda 有它自己的类类型,该类型与存储在 binops 中的值的类型不匹配。

  5. 可以使用一个名为 function 的新的标准库类型解决上述问题,function 定义在 functional 头文件中:

    function 的操作
    function<T> f; f 是一个用来存储可调用对象的空 function,这些可调用对象的调用形式应该与函数类型 T 相同(即 T 是 retType(args))
    function<T> f(nullptr); 显式地构造一个空 function
    function<T> f(obj); 在 f 中存储可调用对象 obj 的副本
    f 将 f 作为条件:当 f 含有一个可调用对象时为真;否则为假
    f(args) 调用 f 中的对象,参数是 args
    定义为 function<T> 的成员的类型
    result type 该 function 类型的可调用对象返回的类型
    argument_type 当 T 有一个或两个实参时定义的类型。如果 T 只有一个实参,则 argument_type 是该类型的同义词;如果 T 有两个实参,则 first_argument_type 和 second_argument_type 分别代表两个实参的类型
    first_argument_type
    second_argument_type
  6. function 是一个模板,和我们使用过的其他模板一样,当创建一个具体的 function 类型时我们必须提供额外的信息。在此例中,所谓额外的信息是指该 function 类型能够表示的对象的调用形式。参考其他模板,我们在一对尖括号内指定类型:

    1
    function<int(int, int)>

    在这里我们声明了一个 function 类型,它可以表示接受两个 int、返回一个 int 的可调用对象。

    1
    2
    3
    4
    5
    6
    7
    function<int(int, int)> f1 = add;             // 函数指针
    function<int(int, int)> f2 = divide(); // 函数对象类的对象
    function<int(int, int)> f3 = [](int i, int j) // lambda
    { return i * j; };
    cout << f1(4, 2) << endl; // 打印 6
    cout << f2(4, 2) << endl; // 打印 2
    cout << f3(4, 2) << endl; // 打印 8

    使用这个 function 类型我们可以重新定义 map

    1
    2
    3
    4
    // 列举了可调用对象与二元运算符对应关系的表格
    // 所有可调用对象都必须接受两个 int、返回一个 int
    // 其中的元素可以是函数指针、函数对象或者 lambda
    map<string, function<int(int, int)>> binops;

    我们能把所有可调用对象,包括函数指针、lambda 或者函数对象在内,都添加到这个 map 中:

    1
    2
    3
    4
    5
    6
    map<string, function<int(int, int)>> binops = {
    {"+", add}, // 函数指针
    {"-", std::minus<int>()}, // 标准库函数对象
    {"/", divide()}, // 用户定义的函数对象
    {"*", [](int i, int j) { return i * j; }}, // 未命名的 lambda
    {"8", mod}}; // 命名了的 lambda 对象

    一如往常,当我们索引 map 时将得到关联值的一个引用。如果我们索引 binops,将得到 function 对象的引用。function 类型重载了调用运算符,该运算符接受它自己的实参然后将其传递给存好的可调用对象

    1
    2
    3
    4
    5
    binops["+"](10, 5); // 调用 add(10, 5)
    binops["-"](10, 5); // 使用 minus<int> 对象的调用运算符
    binops["/"](10, 5); // 使用 divide 对象的调用运算符
    binops["*"](10, 5); // 调用 lambda 函数对象
    binops["%"](10, 5); // 调用 lambda 函数对象

    我们依次调用了 binops 中存储的每个操作。在第一个调用中,我们获得的元素存放着一个指向 add 函数的指针,因此调用 binops["+"](10, 5) 实际上是使用该指针调用 add,并传入 10 和 5。在接下来的调用中,binops["-"] 返回一个存放着 std::minus<int> 类型对象的 function,我们将执行该对象的调用运算符。

  7. 我们不能(直接)将重载函数的名字存入 function 类型的对象中:

    1
    2
    3
    4
    int add(int i, int j) { return i + j; }
    Sales_data add(const Sales data &, const Sales data &);
    map<string, function<int(int, int)>> binops;
    binops.insert({"+", add}); // 错误:哪个 add?

    解决上述二义性问题的一条途径是存储函数指针而非函数的名字:

    1
    2
    int (*fp)(int, int) = add; // 指针所指的 add 是接受两个 int 的版本
    binops.insert({"+", fp}); // 正确:fp 指向一个正确的 add 版本

    同样,我们也能使用 lambda 来消除二义性:

    1
    2
    // 正确:使用 lambda 来指定我们希望使用的 add 版本
    binops.insert({"+", [](int a, int b) { return add(a, b); }});

    lambda 内部的函数调用传入了两个 int,因此该调用只能匹配接受两个 intadd 版本,而这也正是执行 lambda 时真正调用的函数。

14.9 重载、类型转换与运算符

  1. 转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。

14.9.1 类型转换运算符

  1. 转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。

  2. 类型转换运算符(conversion operator)是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下所示:

    1
    operator type() const;

    其中 type 表示某种类型。类型转换运算符可以面向任意类型(除了 void 之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型

    类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成 const 成员。

    Note:一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是 const

  3. 举个例子,我们定义一个比较简单的类,令其表示 0 到 255 之间的一个整数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class SmallInt
    {
    public:
    SmallInt(int i = 0) : val(i)
    {
    if (i < 0 || i > 255)
    throw std::out_of_range("Bad SmallInt value");
    }
    operator int() const { return val; }

    private:
    std::size_t val;
    };

    我们的 SmallInt 类既定义了向类类型的转换,也定义了从类类型向其他类型的转换。其中,构造函数将算术类型的值转换成 smallInt 对象,而类型转换运算符将 SmallInt 对象转换成 int

    1
    2
    3
    SmallInt si;
    si = 4; // 首先将 4 隐式地转换成 SmallInt,然后调用 SmallInt::operator=
    si + 3; // 首先将 si 隐式地转换成 int,然后执行整数的加法

    尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。 因此,我们可以将任何算术类型传递给 SmallInt 的构造函数。类似的,我们也能使用类型转换运算符将一个 SmallInt 对象转换成 int,然后再将所得的 int 转换成任何其他算术类型:

    1
    2
    3
    4
    // 内置类型转换将 double 实参转换成 int
    SmallInt si = 3.14; // 调用 SmallInt(int) 构造函数
    // SmallInt 的类型转换运算符将 si 转换成 int
    si + 3.14; // 内置类型转换将所得的 int 继续转换成 double

    因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class SmallInt;
    operator int(SmallInt &); // 错误:不是成员函数
    class SmallInt
    {
    public:
    int operator int() const; // 错误:指定了返回类型
    operator int(int = 0) const; // 错误:参数列表不为空
    operator int *() const { return 42; } // 错误:42 不是一个指针
    };
  4. 对于类来说,定义向 bool 的类型转换还是比较普遍的现象。

  5. C++11 新标准引入了显式的类型转换运算符(explicit conversion operator):

    1
    2
    3
    4
    5
    6
    7
    class SmallInt
    {
    public:
    // 编译器不会自动执行这一类型转换
    explicit operator int() const { return val; }
    // 其他成员与之前的版本一致
    };

    和显式的构造函数一样,编译器(通常)也不会将一个显式的类型转换运算符用于隐式类型转换

    1
    2
    3
    SmallInt si = 3;          // 正确:SmallInt 的构造函数不是显式的
    si + 3; // 错误:此处需要隐式的类型转换,但类的运算符是显式的
    static_cast<int>(si) + 3; // 正确:显式地请求类型转换

    当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。

    该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行

    • ifwhiledo 语句的条件部分
    • for 语句头的条件表达式
    • 逻辑非运算符(!)、逻辑或运算符(||)、逻辑与运算符(&&)的运算对象
    • 条件运算符(? :)的条件表达式。
  6. 无论我们什么时候在条件中使用流对象,都会使用为 IO 类型定义的 operator bool 例如:

    1
    while (std::cin >> value)

    while 语句的条件执行输入运算符,它负责将数据读入到 value 并返回 cin。为了对条件求值,cinistream operator bool 类型转换函数隐式地执行了转换。如果 cin 的条件状态是 good,则该函数返回为真;否则该函数返回为假。

  7. Best Practices:bool 的类型转换通常用在条件部分,因此 operator bool 一般定义成 explicit 的。

14.9.2 避免有二义性的类型转换

  1. 在两种情况下可能产生多重转换路径。第一种情况是两个类提供相同的类型转换:例如,当 A 类定义了一个接受 B 类对象的转换构造函数,同时 B 类定义了一个转换目标是 A 类的类型转换运算符时,我们就说它们提供了相同的类型转换。

    第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。

    WARNING: 通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。

  2. 在下面的例子中,我们定义了两种将 B 转换成 A 的方法:一种使用 B 的类型转换运算符、另一种使用 A 的以 B 为参数的构造函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 最好不要在两个类之间构建相同的类型转换
    struct B;
    struct A
    {
    A() = default;
    A(const B &); // 把一个 B 转换成 A
    // 其他数据成员
    };
    struct B
    {
    operator A() const; // 也是把一个 B 转换成 A
    // 其他数据成员
    };
    A f(const A &);
    B b;
    A a = f(b); // 二义性错误:含义是 f(B::operator A())
    // 还是 f(A::A(const B &))?

    因为同时存在两种由 B 获得 A 的方法,所以造成编译器无法判断应该运行哪个类型转换,也就是说,对 f 的调用存在二义性。该调用可以使用以 B 为参数的 A 的构造函数,也可以使用 B 当中把 B 转换成 A 的类型转换运算符。因为这两个函数效果相当、难分伯仲,所以该调用将产生错误。

    如果我们确实想执行上述的调用,就不得不显式地调用类型转换运算符或者转换构造函数:

    1
    2
    A a1 = f(b.operator A()); // 正确:使用 B 的类型转换运算符
    A a2 = f(A(b)); // 正确:使用 A 的构造函数

    值得注意的是,我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性。

  3. 另外如果类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,则同样会产生二义性的问题。最简单也是最困扰我们的例子就是类当中定义了多个参数都是算术类型的构造函数,或者转换目标都是算术类型的类型转换运算符。

    例如,在下面的类中包含两个转换构造函数,它们的参数是两种不同的算术类型;同时还包含两个类型转换运算符,它们的转换目标也恰好是两种不同的算术类型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    struct A
    {
    A(int = 0); // 最好不要创建两个转换源都是算术类型的类型转换
    A(double);
    operator int() const; // 最好不要创建两个转换对象都是算术类型的类型转换
    operator double() const;
    // 其他成员
    };
    void f2(long double);
    A a;
    f2(a); // 二义性错误:含义是 f(A::operator int())
    // 还是 f(A::operator double())?
    long lg;
    A a2(lg); // 二义性错误:含义是 A::A(int) 还是 A::A(double)?

    在对 f2 的调用中,哪个类型转换都无法精确匹配 long double。然而这两个类型转换都可以使用,只要后面再执行一次生成 long double 的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。

    当我们试图用 long 初始化 a2 时也遇到了同样问题,哪个构造函数都无法精确匹配 long 类型。它们在使用构造函数前都要求先将实参进行类型转换:

    • 先执行 longdouble 的标准类型转换,再执行 A(double)
    • 先执行 longint 的标准类型转换,再执行 A(int)


    编译器没办法区分这两种转换序列的好坏,因此该调用将产生二义性。

    调用 f2 及初始化 a2 的过程之所以会产生二义性,根本原因是它们所需的标准类型转换级别一致。当我们使用用户定义的类型转换时,如果转换过程包含标准类型转换,则标准类型转换的级别将决定编译器选择最佳匹配的过程:

    1
    2
    3
    short s = 42;
    // 把 short 提升成 int 优于把 short 转换成 double
    A a3(s); // 使用 A::A(int)

    在此例中,short 提升成 int 的操作要优于把 short 转换成 double 的操作,因此编译器将使用 A::A(int) 构造函数构造 a3,其中实参是 s(提升后)的值。

    Note: 当我们使用两个用户定义的类型转换时,如果转换函数之前或之后存在标准类型转换,则标准类型转换将决定最佳匹配到底是哪个。

  4. 除了显式地向 bool 类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。

  5. 当我们调用重载的函数时,从多个类型转换中进行选择将变得更加复杂。如果两个或多个类型转换都提供了同一种可行匹配,则这些类型转换一样好。

    举个例子,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    struct C
    {
    C(int);
    // 其他成员
    };
    struct D
    {
    D(int);
    // 其他成员
    };
    void manip(const C &);
    void manip(const D &);
    manip(10); // 二义性错误:含义是 manip(C(10)) 还是 manip(D(10))
  6. 当调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配,则我们认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换。

    例如当我们调用 manip 时,即使其中一个类定义了需要对实参进行标准类型转换的构造函数,这次调用仍然会具有二义性:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct E
    {
    E(double);
    // 其他成员
    };
    void manip2(const C &);
    void manip2(const E &);
    // 二义性错误:两个不同的用户定义的类型转换都能用在此处
    manip2(10); // 含义是 manip2(C(10)) 还是 manip2(E(double(10)))

    在此例中,C 有一个转换源为 int 的类型转换,E 有一个转换源为 double 的类型转换。对于 manip2(10) 来说,两个 manip2 函数都是可行的:

    • manip2(const C &) 是可行的,因为 C 有一个接受 int 的转换构造函数,该构造函数与实参精确匹配。
    • manip2(const E &) 是可行的,因为 E 有一个接受 double 的转换构造函数,而且为了使用该函数我们可以利用标准类型转换把 int 转换成所需的类型。


    因为调用重载函数所请求的用户定义的类型转换不止一个且彼此不同,所以该调用具有二义性。即使其中一个调用需要额外的标准类型转换而另一个调用能精确匹配,编译器也会将该调用标示为错误。

    Note: 在调用重载函数时,如果需要额外的标准类型转换,则该转换的级别只有当所有可行函数都请求同一个用户定义的类型转换时才有用。如果所需的用户定义的类型转换不止一个,则该调用具有二义性。

14.9.3 函数匹配与重载运算符

  1. 重载的运算符也是重载的函数。

  2. 和普通函数调用不同,我们不能通过调用的形式来区分当前调用的是成员函数还是非成员函数。

    当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。

    当我们调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载,这是因为我们用来调用命名函数的语法形式对于成员函数和非成员函数来说是不相同的。当我们通过类类型的对象(或者该对象的指针及引用)进行函数调用时,只考虑该类的成员函数。而当我们在表达式中使用重载的运算符时,无法判断正在使用的是成员函数还是非成员函数,因此二者都应该在考虑的范围内。

    Note: 表达式中运算符的候选函数集既应该包括成员函数,也应该包括非成员函数。

  3. WARNING: 如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

小结

  1. 一个重载的运算符必须是某个类的成员或者至少拥有一个类类型的运算对象。重载运算符的运算对象数量、结合律、优先级与对应的用于内置类型的运算符完全一致。当运算符被定义为类的成员时,类对象的隐式 this 指针绑定到第一个运算对象。赋值、下标、函数调用和箭头运算符必须作为类的成员。

  2. 如果类重载了函数调用运算符 operator(),则该类的对象被称作“函数对象”。这样的对象常用在标准函数中。lambda 表达式是一种简便的定义函数对象类的方式。

  3. 在类中可以定义转换源或转换目的是该类型本身的类型转换,这样的类型转换将自动执行。只接受单独一个实参的非显式构造函数定义了从实参类型到类类型的类型转换;而非显式的类型转换运算符则定义了从类类型到其他类型的转换。

术语表

第 14 章术语表


Thank you for your donate!