What & How & Why

重载运算与类型转换

C++ Primer 笔记 第十四章


C++ 允许用户通过运算符重载Operator Overloading)为类对象定义运算,以及相关的类型转换。

基础概念

重载的运算符实际上是有特殊名字的函数。该函数的名称由两部分组成:关键字 operator 与要重载的运算符符号。该函数拥有返回值,参数列表和函数体。

重载的运算符函数有几个特点:

  • 参数数量需要与bulit-in 运算符相同,也就是运算符重载不能改变运算符的定义
  • 如果重载的运算符函数是成员函数,则其 left operand 默认绑定当前对象 this

重载运算符有两种形式:是成员函数,或者参数列表中至少包含一个 class type 的参数 比如下方这样的写法就是错误的:

// error: cannot redefine the built-in operator for ints
int operator+(int, int);

支持重载的运算符

C++ 支持重载的运算符如下图:



有几点需要注意的是:

  • C++ 只允许重载已有的,被允许的运算符。除此之外的自定义运算符是不允许的
  • +-*& 的一元 / 二元版本均可被重载
  • 被重载的运算符会保留运算符优先级结合律
直接调用重载运算符

由于重载的运算符是函数,因此该重载存在使用符号调用,或者直接调用的两种方式:

// equivalent calls to a nonmember operator function
data1 + data2;           // normal expression
operator+(data1, data2); // equivalent function call

//member function version
data1 += data2;             // expression-based ''call''
data1.operator+=(data2);    // equivalent call to a member operator function

运算符重载的注意事项

某些运算符不应该被重载

如果默认的运算符拥有以下的特性:

  • 运算符自身保证了 order of evaluation
  • 运算符自身具有 short-circuit evaluation(也就是某个表达式得出结果之前不会执行另外的表达式)
  • 运算符对类对象的操作已经被默认定义

则这种运算符不应该被重载。比如 &&||,重载该运算符会导致其失去 short-circuit evaluation 的特性;而对于 ,&(取地址)等操作符,C++ 已经提供了这些运算符对类的操作,因此不需要进行重载。

重载应该保证定义上的一致性

在设计类的运算时,需要考虑两点:

  • 类对象是否需要某类运算
  • 该运算在逻辑上与 built-in 类型的运算是否有一致性

比如以下常见的,保证一致性例子:

  • 如果类中定义了输入输出,则可以通过重载 shift operator 来与 bulit-in type 统一输入输出的形式
  • 如果类中定义了等价的比较,那么应该重载 ==!=
  • 如果类中定义了单一的自然排序,那么应该重载 <。而重载 < 意味着应该重载其他所有的关系运算符。

需要注意的是,这种一致性也包括返回值;也就是说:

  • 逻辑 / 关系运算应该返回 bool 类型
  • 算数运算应该返回 class 类型的
  • 赋值 / 复合赋值运算应该返回 left-hand operand引用

总的来说,重载运算符应该基于默认运算符的意义进行设计。我们可以延伸,映射该意义到类对象上,但不能违背已有的意义。正确的使用重载可以使程序变得更加直观,而滥用只会造成更多的歧义。

赋值运算符的设计应该与合成的版本类似:赋值之后,左右的值应该相等,且运算应该返回左边对象的引用。如果类中定义了算数运算或者位运算,则我们应该提供对应的复合赋值运算。

成员或者非成员的选择

重载的运算符函数是否应该是成员函数取决于该运算符的特性:

  • 必须定义为成员函数的:赋值 =下标 []调用 ()成员访问
  • 必须定义为非成员函数的:I / O shift operator
  • 应该定义为成员函数的:
    • 复合赋值运算符
    • 改变对象状态,或与其对象类型联系紧密的运算符,比如自增 ++自减 解引用 *
  • 应该定义为非成员函数的:
    • Symmetric Operators(指可能会转换其算子的运算符),比如算术运算符等价/关系运算符运算符

值得提出的是,当重载 Symmetric operators 的时候,需要考虑类型转换的问题。这是因为 Symmetric operators 并不强制算子的位置:通常在 Symmetric operators 出现的运算中,左算子与右算子是可以互换位置的。

另外,如果某个运算符被重载为了成员函数,则其算子的位置就固定下来了。这里特指左算子,必须是该重载运算符所在类的对象

string s = "world";
string t = s + "!";  // ok: we can add a const char* to a string
string u = "hi" + s; // would be an error if + were a member of string

输入输出运算符

重载输出运算符 <<

输出运算符的重载对参数与返回值有如下的要求:

  • 第一个参数应该是 non-const 的 ostream 对象的引用
    • Non-const 是因为写入流会改变当前对象的状态
    • 引用是因为 ostream 对象无法被拷贝
  • 第二个参数应该是 reference to const of the class type:const 是因为打印不会改变当前对象的内容
  • 返回值应该是 non-const 的 ostream 对象的引用:这么设计是为了维护与默认输出运算符的一致性

Sales_data 类的例子如下:

ostream &operator<<(ostream &os, const Sales_data &item)
{
    os << item.isbn() << " " << item.units_sold << " "
       << item.revenue << " " << item.avg_price();
    return os;
}

重载的输出运算符应该最小化格式化操作

输出运算符通常不会对输出的内容进行格式化,尤其是换行的操作。该操作最好交由使用者来实现。

重载的输入输出运算符必须是非成员函数

重载的输入输出运算符行必须非成员函数。这是因为当重载运算符被定义为成员函数时,左边的算子必须是对应类的对象。如果不做调整,那么 istreamostream 对象就会被放到右边:

Sales_data data;
data << cout; // if operator<< is a member of Sales_data
若要将 istreamostream 对象放到运算符的左边,则重载运算符必须是 istreamostream 类的成员函数。而 istreamostream 类属于标准库的定义,我们不能为其添加成员函数。因此,输入输出重载运算符必须被设计为非成员函数。

重载输入运算符 >>

重载的输入运算符对参数以及返回值的要求是:

  • 第一个参数为 istream 对象的 non-const 引用
  • 第二个参数为接收输入类对象的non-const 引用,因为输入会修改该对象的内容
  • 返回值为 istream 对象的 non-const 引用,用于维护输出流的一致性
重载输入运算符需要检测输入是否合法

与重载的输出运算符最大的区别是,重载的输入运算符需要检测输入的结果是否合法。检测从以下几个方面入手:

  • 检测的时机是当所有的输入完成以后
  • 检测的对象是输入对象 fail flag 是否发生了变化
  • 非法输入的一般处理办法是直接赋予当前被输入对象一个默认值

Sales_data 类为例,重载输入的实现如下:

istream &operator>>(istream &is, Sales_data &item)
{
    double price;  // no need to initialize; we'll read into price before we use it
    is >> item.bookNo >> item.units_sold >> price;
    if (is)        // check that the inputs succeeded
        item.revenue = item.units_sold * price;
    else
        item = Sales_data(); // input failed: give the object the default state
    return is;
}

之所以要等到所有输入完成以后进行检测,是需要确保所有的输入值都是有效的;单独检查 fail 位是否正常,是因为 fail 位意味着输入出错但 stream 还在正常运行;如果是 badbit 或者 eofbit 这些由 stream 本身带来的问题,应该交由 stream 自身进行判定。当输入出错时,意味着当前类对象接收的内容很可能已经被破坏了;这时给与其一个正常的默认值可以保证其内部数据的完整性。

除此之外,很多时候输入运算符还需要进行额外的检测。比如上例中的 bookNo,我们应该检测其输入格式是否合法。

算术与关系运算符

算术运算符与关系运算符的设计要点如下:

  • 该类运算符应该被重载为非成员函数,因为需要允许左右算子的转换
  • 该类运算符的参数应该是 const T& 类型,因为不会改变算子的状态
  • 该类运算符的返回值的方式是值返回,其返回值是运算结果临时变量的拷贝
  • 如果算术运算符与赋值运算符都有定义,则复合算术运算符也应该被定义。

除此之外,使用复合运算符实现对应的算术运算符效率会更高:比如下例中,Sale_data 中的 + 运算重载实现:

// assumes that both objects refer to the same book
Sales_data
operator+(const Sales_data &lhs, const Sales_data &rhs)
{
    Sales_data sum = lhs;  // copy data members from lhs into sum
    sum += rhs;             // add rhs into sum
    return sum;
}

等价运算符

重载等价运算符 == 主要目的在于测试两个对象是否等价。通常,如果两个对象中所有对应的数据成员都相等,则该两个对象会被视作等价。比如 Sales_data 类的等价实现:

bool operator==(const Sales_data &lhs, const Sales_data &rhs)
{
    return lhs.isbn() == rhs.isbn() &&
           lhs.units_sold == rhs.units_sold &&
           lhs.revenue == rhs.revenue;
}

bool operator!=(const Sales_data &lhs, const Sales_data &rhs)
{
    return !(lhs == rhs);
}
等价运算的重载需要遵循以下的原则:

  • 如果类对象之间需要进行比较,那么 == 重载比新定义比较函数更好。这样做可以避免学习新的函数,也方便该类在某些算法和容器中的使用。
  • 等价应该被设计为有传递性,比如由 a==bb==c 可以得知 a==c
  • 如果类中重载了 ==,该运算应该判断给定的对象中是否存在等价的数据
  • == 应该与 != 成对出现,且后实现的操作应该基于先实现的操作实现;比如 == 先实现,则 != 可以用 ! (obj1==obj2) 的方式实现。

关系运算符

关系运算符在某些需要进行排序的容器和算法中很有用,特别是 <。关系运算符的定义应该满足两个条件:

  1. 排序的关系应该遵循严格弱序(与关系容器的 key 要求一致)
  2. 排序的意义需要与等价运算符的意义一致,也就是说 != 意味着参与比较的对象有一个需要 < 另外一个

第二点在评估是否需要定义关系运算符上尤为重要。以 Sales_data 类举例,Sales_data 类不应该实现关系运算符:

  1. Sales_data 通过比较 isbnrevenue, units_sold 三个成员来决定两个 Sales_data 类对象是否相等
  2. 假设我们使用 isbnSales_data 对象进行排序,当 isbn 相等,而 units_soldrevenue 不相等的时候:
    1. 两个对象是不相等的(通过 == 判定)
    2. 但两个对象又同时不小于对方(通过 < 求反得出此判定),得出两个对象相等的结论

由上可以看出,< 的定义得出的结果与 == 并没有保持一致,因此这种情况下我们不应该定义关系运算符。

只有在单一的排序逻辑存在,并且该排序逻辑与 == 可以得出一致结果的时候,才可以进行关系运算符的重载。

赋值运算符(补充)

赋值运算符与初始化列表

除了之前提到过得赋值运算符的拷贝 / 移动形式,赋值运算符还允许将不同类型的数据赋予当前对象。一个重要的例子是 initializer_list:比如 vector 就可以使用初始化列表进行赋值:

vector<string> v;
v = {"a", "an", "the"};
这种用法下的赋值运算符重载需要遵循以下要求:

  • 返回值同样需要时当前对象的引用(与拷贝 / 移动赋值运算符一致)
  • 不需要进行自我赋值的检测,因为参数类型与 this 指代的对象不同,无法自我赋值
  • 必须定义为类成员,因为返回值要求。

下面是 StrVec 类中使用初始化列表赋值的赋值运算符实现(拷贝版本):

StrVec &StrVec::operator=(initializer_list<string> il)
{
    // alloc_n_copy allocates space and copies elements from the given range
    auto data = alloc_n_copy(il.begin(), il.end());
    free();   // destroy the elements in this object and free the space
    elements = data.first; // update data members to point to the new space
    first_free = cap = data.second;
    return *this;
}

复合赋值运算符

书中推荐将复合赋值运算符与赋值运算符一起定义为成员函数,并返回左算子的引用。下面是 Sales_data 类中复合赋值运算符的实现:

// member binary operator: left-hand operand is bound to the implicit this pointer
// assumes that both objects refer to the same book
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}

下标运算符

如果某个类是容器类,并且该容器支持通过位置获取元素,则我们应该为其重载下标运算符(Subscript operator[]。下标运算符的重载需要遵循以下规则:

  • 必须是成员函数
  • 返回值是被获取元素的引用,这是为了与默认下标运算符保持一致
  • 针对 const / non-const 对象,下标运算符通常需要定义两个版本。由于返回引用,常量版本的返回值也需要加 const 确保不会被修改

下面是 StrVec 的下标操作符实现:

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]; }
    // other members as in § 13.5 (p. 526)
private:
    std::string *elements;   // pointer to the first element in the array
};
使用的时候需要确保下标访问的元素不为空(通常这部分验证可以集成到下标操作符的重载中)。除此之外,常量版版本的重载无法进行赋值:
// assume svec is a StrVec
const StrVec cvec = svec; // copy elements from svec into cvec
// if svec has any elements, run the  string empty function on the first one
if (svec.size() && svec[0].empty())  {
    svec[0] = "zero"; // ok: subscript returns a reference to a string
    cvec[0] = "Zip";  // error: subscripting cvec returns a reference to const
}

自增自减运算符

自增与自减运算符(++)的重载大多数是为了迭代器类而实现的。迭代器通过对自身的自增 / 自减,实现了在元素序列中移动的目的。由于自增自减会改变迭代器状态,因此推荐将其声明为成员函数

自增与自减的重载分为两个版本:前置版本 prefix 与 后置版本 postfix。两种版本有实现上的差异。

前置自增/自减运算符

前置自增运算符的重载需要满足几个条件:

  • 返回值是当前对象的 non-const 引用
  • 参数列表为空

通常实现上,由于需要判断移位后的结果是否可以被解引用,因此需要检查当前迭代器所在位置。而根据移动方向的不同,自增自减的实现方法也不同。以 StrBlobPtr 为例:

// prefix: return a reference to the incremented/decremented object
StrBlobPtr& StrBlobPtr::operator++()
{
    // if curr already points past the end of the container, can't increment it
    check(curr, "increment past end of StrBlobPtr");
    ++curr;       // advance the current state
    return *this;
}

StrBlobPtr& StrBlobPtr::operator--()
{
    // if curr is zero, decrementing it will yield an invalid subscript
    --curr;       // move the current state back one element
    check(curr, "decrement past begin of StrBlobPtr");
    return *this;
}
可以看到上面的实现中:

  • 自增版本的实现是先检测,再自增
  • 自减版本的实现是先自减,再检测

这么做的原因是基于自增 / 自减的结果带来的影响:

  • 如果是自增,那么需要验证的实际上是当前位置的合法性。如果当前位置已经超出序列的范围了,那么自增后的迭代器肯定是超出范围的。
  • 如果是自减,那么需要检测自减后的位置。比如 off-the-end 迭代器,该迭代器不指向任何有效内容,但其自检后则指向元素序列中最后一个元素。除此之外,check 能很好的处理在序列首部的左移迭代器情况,因为对 0 进行自减会得到一个非常大的正数。

后置自增/自减运算符

解决前置 / 后置版本定义中的二义性

Built-in type 后置自增 / 自减运算符与前置版本最大的一个区别是,后置版本返回的是没有自增之前的对象的状态(以值方式返回);而除此之外并没有其他的区别。这带来一个问题,由于返回值的不同不计入重载的区别,因此前置版本与后置版本的参数数量和类型都是相同的;因此同时定义两个版本会带来二义性。

为此,C++ 定义后置版本会额外的接收一个 int 类型的参数,用于区分前置版本与后置版本。当使用后置版本的时候,编译器默认会传递 0 作为 argument;而前置版本在被调用的时候则没有该 int 参数。需要注意的是,该参数唯一的作用是区分两个版本,因此不应该用于使用。

StrBlobPtr 的后置版本实现

后置版本的实现可以基于前置版本:

class StrBlobPtr {
public:
    // increment and decrement
    StrBlobPtr operator++(int);    // postfix operators
    StrBlobPtr operator--(int);
    // other members as before
};

// postfix: increment/decrement the object but return the unchanged value
StrBlobPtr StrBlobPtr::operator++(int)
{
    // no check needed here; the call to prefix increment will do the check
    StrBlobPtr ret = *this;   // save the current value
    ++*this;     // advance one element; prefix ++ checks the increment
    return ret;  // return the saved state
}
StrBlobPtr StrBlobPtr::operator--(int)
{
    // no check needed here; the call to prefix decrement will do the check
    StrBlobPtr ret = *this;  // save the current value
    --*this;     // move backward one element; prefix -- checks the decrement
    return ret;  // return the saved state
}
可以看出来,后置版本的逻辑中,自增与返回值是分开的:

  1. 首先使用临时对象存储当前对象的状态
  2. 通过使用 ++*this 这行语句,调用了前置版本以及其附带的元素有效性检查
  3. 返回之前存储的状态的拷贝

除此之外,参数中 int 不应该被用于其他地方,因此我们没有给与名字。

显式的调用自增自减函数

自增 / 自减也可以函数的方式调用:

StrBlobPtr p(a1); // p points to the vector inside a1
p.operator++(0);  // call postfix operator++
p.operator++();   // call prefix  operator++
在这里,参数 0 是作为区分调用版本的唯一标志,因此不能省略。带 0 参数的版本是后置版本。

成员访问运算符

成员访问运算符包括 * 解引用运算符与 箭头运算符,主要用于迭代器类与智能指针类中。比如 StrBlobPtr 可以重载这两个运算符,用于读取当前位置的元素:

class StrBlobPtr {
public:
    std::string& operator*() const
    { auto p = check(curr, "dereference past end");
      return (*p)[curr];  // (*p) is the vector to which this object points
    }
    std::string* operator->() const
    { // delegate the real work to the dereference operator
     return & this->operator*();
    }
    // other members as before
};
该实现的几个注意事项:

  • 检查当前的位置是否有效,有效则返回当前 vector 的 shared_ptr p
  • 通过对 p 解引用 + 下标的组合使用访问指定的元素
  • 箭头运算符的实现是直接使用当前的 StrBlobPtr 调用解引用的重载版本

由于解引用和箭头运算都是访问类型的函数,因此这两个函数都是 const 成员函数。特别需要注意解引用重载返回了普通引用。这是因为我们可以确定 StrBlobPtr 不会与 const StrBlob 绑定(否则是需要加 const 的,不然可以通过返回值修改对象)。

箭头运算符返回值的限制

与其他的运算符重载不同,箭头运算符的重载不能重新诠释该运算符的意义。无论如何定义,该运算符始终意味着对类成员的访问。对箭头运算符的重载只能改变被获取的成员

当使用箭头运算符的时候,箭头的左边必须是一个指向类对象的指针.。比如 point→mem 可以写作如下形式:

(*point).mem;          // point is a built-in pointer type
point.operator()->mem; // pint is an object of class type

  • 第一种形式是解引用后使用对象调用成员
  • 第二种形式是使用对象直接调用 访问成员

这两种不同的形式体现出重载箭头运算符拥有类似于递归的行为,即:

  • 重载箭头运算符期望最终获取一个build-in 指针,并使用该指针调用 built-int type 的箭头运算符
  • 如果得到的结果是一个指针类对象,该类对象也重载了箭头运算符,则接着使用当前的类对象继续调用对应的箭头运算符。

也就是说,可以将指针类对象视作中间过程。每使用一次该对象重载的箭头运算符,就会解引用指针对象,并访问当前类对象中的指针成员。得到的指针成员可以用于调用下一个类对象中的指针成员,或是最终的实体成员;而调用的过程也是通过箭头运算符的重载来实现的:


这也解释了为了什么箭头运算符重载的返回值必须是指针的问题。

函数调用运算符

当一个类重载了函数调用运算符(Function-Call Operator)时,该类的对象可以像函数一样使用。由于类对象可以定义数据成员实现状态的存储,使用起来也较普通函数更为灵活。

一个求绝对值的函数调用重载类的实现如下:

struct absInt {
    int operator()(int val) const {
        return val < 0 ? -val : val;
    }
};
该实现中,我们通过重载函数调用运算,实现了求绝对值的功能。通过调用该类的对象,可以像函数一样得到某个值的绝对值:
nt i = -42;
absInt absObj;      // object that has a function-call operator
int ui = absObj(i); // passes i to absObj.operator()
需要注意的是,函数调用的重载需要被定义为成员函数。该重载可以有多个版本。版本通过参数的数量与类型进行区分。由于函数调用的重载通过类对象实现了函数的功能,因此我们称该类对象为函数对象Function Object)。

函数对象类与状态

之前提到,函数对象类可以通过额外的数据成员来管理函数的状态。下面是一个打印带自定义分隔字符 string 的例子,其实现中使用了额外的数据成员存储自定义分隔字符:

class PrintString {
public:
    PrintString(ostream &o = cout, char c = ' '):
        os(o), sep(c) { }
    void operator()(const string &s) const { os << s << sep; }
private:
    ostream &os;   // stream on which to write
    char sep;      // character to print after each output
};
重载的函数调用运算符则负责输出获取的 string,以及自定义的分隔字符。调用的时候,只需要使用 PrintString 类对象读取 string 进行打印即可:
PrintString printer;   // uses the defaults; prints to cout
printer(s);            // prints s followed by a space on cout
PrintString errors(cerr, '\n');
errors(s);             // prints s followed by a newline on cerr
该类函数对象也可以用于泛型算法。比如将容器 vs 的所有元素以 cerr 加换行进行输出:
for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
需要注意这里的类对象是使用构造函数创建的临时对象。每个临时对象会对应每个元素,并将其打印出来。这里需要与一般的函数对象调用进行区别。

Lambda 是函数对象

函数对象的使用与 lambda 类似。实际上,lambda 在使用中被编译器转换为了一个无名类的无名对象。该对象也是通过函数调用运算符的重载来实现功能的。比如下面的 lambda 实际上与 ShorterString 类等价:

// sort words by size, but maintain alphabetical order for words of the same size
stable_sort(words.begin(), words.end(),
            [](const string &a, const string &b)
              { return a.size() < b.size();});
              
//equivalent to the lambda above
class ShorterString {
public:
    bool operator()(const string &s1, const string &s2) const
    { return s1.size() < s2.size(); }
};
需要注意的是,lambda 不会改变捕获的变量。这一点体现到函数调用运算符中则意味着该运算符的重载应该是 const 成员函数mutable lambda 则对应 non-const 成员函数)。在算法中使用该函数对象与之前的方式一致,第三个参数时一个临时的,新创建的函数对象:
stable_sort(words.begin(), words.end(), ShorterString());

如何用函数对象表示带捕获列表的 lambda

lambda 分为引用捕获与值捕获。对应的函数对象的实现中,这两种捕获被进行了区分处理:

  • 引用捕获:由于 lambda 本身不保证引用捕获列表中变量的有效性,因此在函数对象中不需要对引用类型的变量做存储。
  • 值捕获:值捕获列表中,原有变量会被拷贝并存储在 lambda 中。因此,这些变量也需要被存储到函数对象中。

一个值捕获 lambda 转换为函数对象的例子:

// get an iterator to the first element whose size() is >= sz
auto wc = find_if(words.begin(), words.end(),
            [sz](const string &a)
其对应函数对象的实现为:
class SizeComp {
    SizeComp(size_t n): sz(n) { } // parameter for each captured variable
    // call operator with the same return type, parameters, and body as the lambda
    bool operator()(const string &s) const
        { return s.size() >= sz; }
private:
    size_t sz; // a data member for each variable captured by value
};
这里,捕获列表中的变量以私有数据成员的形式存储到了函数对象 SizeComp 中。此外,函数对象还需要一个指定的构造函数提供接口对其进行赋值:
// get an iterator to the first element whose size() is >= sz
auto wc = find_if(words.begin(), words.end(), SizeComp(sz));
为什么不使用合成的默认构造函数?

这是因为使用 lambda 生成的函数类中,默认构造函数是被删除的(赋值运算符也是被删除的)。该类中包含一个默认的析构函数;其他的拷贝成员是否是被删除要取决于被捕获的数据成员类型

标准库函数对象

标准库定义了一系列的函数对象,用于表示算术 / 关系 / 逻辑运算符。这些函数对象定义于 <functional> 头文件中:



这些函数对象的特点是:

  • 名字与运算符的意义相似
  • 实现上都是模板类,需要指定运算的类型
  • 可以嵌套

一些使用例子:

plus<int> intAdd;       // function object that can add two int values
negate<int> intNegate;  // function object that can negate an int value
// uses intAdd::operator(int, int) to add 10 and 20
int sum = intAdd(10, 20);         // equivalent to sum = 30
sum = intNegate(intAdd(10, 20));  // equivalent to sum = -30
// uses intNegate::operator(int) to generate -10 as the second parameter
// to intAdd::operator(int, int)
sum = intAdd(10, intNegate(10));  // sum = 0

在算法中使用标准库函数对象

标准库函数对象通常与算法搭配使用,用于复写算法中默认的运算符。比如 std::sort 默认的排序规则 < 可以用 greater 复写:

// passes a temporary function object that applies the < operator to two strings
sort(svec.begin(), svec.end(), greater<string>());
通过该复写,svec 中的 string 会按降序排列。

标准库函数对象与指针

标准库函数对象有一个很重要的特性:这些对象代表的运算可以正确的应用到指针上。通常情况下,不同容器的指针进行运算的结果是未定义的;但通过使用标准库函数对象,我们仍然可以对这些指针进行运算。比如现在有一个 vector<string*>,而我们希望按照指针的地址对 vector 中的元素进行排序:

vector<string *> nameTable;
// ok: library guarantees that less on pointer types is well defined
sort(nameTable.begin(), nameTable.end(), less<string*>());
而如果使用自定义的 lambda,则无法进行排序:
// error: the pointers in nameTable are unrelated, so < is undefined
sort(nameTable.begin(), nameTable.end(),
     [](string *a, string *b) { return a < b; });
实际上,关系容器中的排序就是通过标准库函数对象 std::less<key_type> 实现的。因此,即便关系容器的 key 是指针类型,也可以进行排序。

可调用对象与函数

C++ 中提供了如下几种可调用对象:

  • 函数
  • 指向函数的指针
  • lambda
  • bind 创建的对象
  • 函数对象

实现中,上述的可调用对象在具体的类型上可能有一些差异。但同时,这些可调用对象可能具有相同的 Call Signature,也就是:

  • 接收的参数数量与类型相同
  • 返回值的类型相同

比如下面的可调用对象,他们的 Call Signature 都是 int(int,int)

// ordinary function
int add(int i, int j) { return i + j; }
// lambda, which generates an unnamed function-object class
auto mod = [](int i, int j) { return i % j; };
// function-object class
struct divide {
    int operator()(int denominator, int divisor) {
        return denominator / divisor;
    }
};
假设我们希望将上述的各种可调用对象组合起来做一个 int 的计算器。一个比较好的方法是通过建立函数表Function Table),来管理这些可调用对象。使用 map 可以很容易的实现该表:
// maps an operator to a pointer to a function taking two ints and returning an int
map<string, int(*)(int,int)> binops;
上述的 map 中,key 的类型是 string,代表对应运算的符号;value 的类型是函数名(指针),代表了具体的可调用对象。当调用指定可调用对象时,只需要用下标访问对应的函数名既可。

标准库 function 类型

上面的例子中有一个问题:该 map 无法存储 lambda 类型的可调用对象:

binops.insert({"%", mod}); // error: mod is not a pointer to function
这是因为 int(*)(int,int) 这样的指针是具体的类型,并不适用于 lambda。lambda 返回的是一个无名的类类型,因此不能直接将 lambda 存储到该 map 中。

C++ 提供了 function 类型来解决这个问题。该类型实现于 <functional> 头文件中,基本的操作如下:



与具体类型不同,标准function 类型是一种模板类型,按照 Call Signature 来创建类型。比如之前的 int(int,int) 可以声明为:
function<int(int, int)>
也就是说,所有的,有两个 int 参数 与 int 返回值的对象,都可以用上述的 function 类型表示。比如:
function<int(int, int)> f1 = add;    // function pointer
function<int(int, int)> f2 = divide();  // object of a function-object class
function<int(int, int)> f3 = [](int  i, int j) // lambda
                             { return i * j; };
cout << f1(4,2) << endl; // prints 6
cout << f2(4,2) << endl; // prints 2
cout << f3(4,2) << endl; // prints 8
因此,将 function 类型用于 map 中,可以避免之前的问题:
// table of callable objects corresponding to each binary operator
// all the callables must take two ints and return an int
// an element can be a function pointer, function object, or lambda
map<string, function<int(int, int)>> binops = {
    {"+", add},                  // function pointer
    {"-", std::minus<int>()},    // library function object
    {"/",  divide()},               // user-defined function object
    {"*", [](int i, int j) { return i * j; }}, // unnamed lambda
    {"%", mod} };                // named lambda object
由于 function 类型实际上是通过重载函数调用运算符实现的,因此调用的时候需要提供对应的参数:
binops["+"](10, 5); // calls add(10, 5)
binops["-"](10, 5); // uses the call operator of the minus<int> object
binops["/"](10, 5); // uses the call operator of the divide object
binops["*"](10, 5); // calls the lambda function object
binops["%"](10, 5); // calls the lambda function object

重载函数与 function 类型

当程序中存在函数的重载时,不能将函数名作为 function 类型使用:

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} ); // error: which add?
这种情况下有两种办法:

  • 重命名函数的指针作为 function 类型的参数
  • 使用 lambda 作为 function 类型的参数

int (*fp)(int,int) = add; // pointer to the version of add that takes two ints
binops.insert( {"+", fp} ); // ok: fp points to the right version of add
// ok: use a lambda to disambiguate which version of add we want to use
binops.insert( {"+", [](int a, int b) {return add(a, b);} } );

重载,类型转换,运算符

C++ 允许用户通过重载类型转换运算符的方式自定义 class-type 与 其他类型的转换。这类转换被称为类类型转换class-type conversions),也被称为用户自定义转换user-defined converstions)。

类型转换运算符

类型转换运算符(conversion operator)负责将 class 类型的值转换为其他类型的值。该运算符有几个特点:

  • 不能定义到 arrayfunction 类型的类型转换
  • 没有显式的返回值(也不能写),返回值类型不能是 void,且需要满足表达式的条件
  • 没有显式的参数列表(也不能写),因为类型转换运算符是隐式的应用的
  • 必须是成员函数
  • 应该是 const 成员,因为类型转换不会改变被转换的对象

其定义写法如下:

operator type() const;
其中 type 为需要转换的类型。

定义类型转换运算符

一个简单的类类型与 int 的类型转换定义如下:

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 类型可以通过重载的运算符转换为 int 类型:
SmallInt si;
si = 4; // implicitly converts 4 to SmallInt then calls SmallInt::operator=
si + 3; // implicitly converts si to int followed by integer addition
除此之外用户自定义的类型转换结果用于计算时也可能会发生隐式转换:
// the double argument is converted to int using the built-in conversion
SmallInt si = 3.14; // calls the SmallInt(int) constructor
// the SmallInt conversion operator converts si to int;
si + 3.14; // that int is converted to double using the built-in conversion
上述例子中,3.14 被隐式转换成了 int,再通过自定义转换为了 SmallInt。输出的时候,si 首先被转换为了 int,之后与 3.14 相加的结果会隐式转换为 double

避免滥用自定义类型转换

重载类型转换运算符有一个前提:class-typeconversion type 之间需要存在一一映射的关系single mapping)。

也就是说,转换只能在没有歧义的对应关系中发生。比如类类型 dateint。假设一个 date 的对象记做 1900-1-10,那么可以找出很多种基于 int 的表达方式:比如记做 190011,也比如说可以记做从某个时间点开始时经过的天数;此时的转换是存在歧义的。

当我们可以找出不止一种,但又是合理的表达方式的时候,类型转换运算符是不应该被重载的。这种情况下,我们应该定义不同的成员函数来提取不同的信息。

explicit 类型转换运算符

在类中定义类类型到 bool 类型的转换是比较常见的。但在早期的 C++ 版本中,这样的转换(隐式转换)通常会带来一些问题。比如下面的例子:

int i = 42;
cin << i; // this code would be legal if the conversion to bool were not explicit!
假设上面的例子中, istream 中定义了 istreambool 的转换:

  1. 首先,cin 并没有定义 « 输出运算符,因此这里 cin 会被转换为 bool
  2. 由于 cin 被转化为 bool,因此这里的 « 被诠释为移位运算符
  3. 最后,cin 被提升为 int,并向左位移 42 个位置

为了避免这样的情况发生,C++11 提供了 explicit 关键字。该关键字的功能与应用到构造函数上一致,也是防止隐式转换的发生:

class SmallInt {
public:
    // the compiler won't automatically apply this conversion
    explicit operator int() const { return val; }
    // other members as before
};
重载的类型转换运算符被 explicit 修饰后,只能通过显式的 cast 转换来进行类型转换;否则必须提供匹配的类型:
SmallInt si = 3;  // ok: the SmallInt constructor is not explicit
si + 3; // error: implicit is conversion required, but operator int is explicit
static_cast<int>(si) + 3; // ok: explicitly request the conversion
需要注意的是,上述的限制有一个非常重要的例外。当 explicit 修饰的类型转换用于判断条件的时候,该类型转换以隐式转换的方式运作。判断条件特指如下的运算:

  • 用于 if / while /do while 的判断条件
  • 用于 for 头部中的判断条件
  • 用于逻辑运算 ! / || / && 中的判断条件
  • 用于三目运算符 ?: 中的判断条件
IO类型中,基于 C++11 的 bool 转换

早期的 C++ 中,IO 类型通过定义到 void* 的类型转换来避免上述的问题。而 C++ 11 中, IO 类型则定义了到 boolexplicit 转换来取代该写法。因此,当我们使用 stream 对象作为判断条件时,都会使用到该转换。比如:

while (std::cin >> value)
此处的 std::cin 被转换为成了 bool。该值为 true 意味着当前 std::cin 对象状态正常(good)。

定义类类型到 bool 类型的转换通常是为了使用类类型作为判断条件。因此最好将该重载定义为 explicit 类型。

避免转换中的二义性

如果类中存在多个类型转换时,需要确保 class-type 与目标类型之间的转换是唯一的。如果对同一个类型的转换存在多种方式,很可能会导致潜在的二义性错误。有两个比较典型的例子:

  • class-type 之间存在交互转换。具体来说,B 通过 A 的构造函数转换为 A,而 B 中又重载了到 A 的转换运算
  • 类中定义了多种将 class-type 转换为目标类型(绝大部分是算术类型)的方式
交互转换导致二义性的实例

下面的代码是一个交互转换导致二义性的例子:

// usually a bad idea to have mutual conversions between two class types
struct B;
struct A {
    A() = default;
    A(const B&);        // converts a B to an A
    // other members
};
struct B {
    operator A() const; // also converts a B to an A
    // other members
};
A f(const A&);
B b;
A a = f(b); // error ambiguous: f(B::operator A())
            //          or f(A::A(const B&))
该段代码中:

  • A 可以通过构造函数将 B 转换为 A 类型
  • B 中定义了 B 到 A 的转换,因此可以通过隐式转换将 B 转换为 A

当调用 A a = f(b) 时,第一步是要将 B 类型的 参数 b 转换为 A 类型。而此时编译器无法确定是使用 类 A 中的转换方式还是使用 类 B 中的转换方式,因此导致了二义性。这种情况下,必须显式的指定转换的方式,才能进行正确的调用,比如:

A a1 = f(b.operator A()); // ok: use B's conversion operator
A a2 = f(A(b));           // ok: use A's constructor

转换方式不唯一导致二义性的实例

转换方式不唯一导致二义性通常发生在类类型到算术类型的转换过程中。比如下面的例子:

struct A {
    A(int = 0);   // usually a bad idea to have two
    A(double);    // conversions from arithmetic types
    operator int() const;    // usually a bad idea to have two
    operator double() const; // conversions to arithmetic types
    //   other members

};
void f2(long double);
A a;
f2(a); // error ambiguous: f(A::operator int())
       //          or f(A::operator double())
long lg;
A a2(lg); // error ambiguous: A::A(int) or A::A(double)
上例中,类 A 中定义了:

  • 两种构造方式,将不同的算术类型转换为 A 类型
  • 两种转换方式,将 A 转换为不同类型的算术类型

这样的设计会导致以下的调用出现二义性:

  • 当调用者使用 A 类型的参数时,A 类型需要首先转换为适合 long double 类型的参数。而 A 中并没有定义直接转换为 long double 的转换。此时,我们既可以通过先转换为 int 再转换为 long double,又可以通过先转换为 double 再转换为 long double 的方式完成上述的转换。因此,编译器无法决定选择哪种方式,二义性产生。
  • 同理,当调用者使用 long 类型构造 A 类型对象时,由于 A 中存在两种使用算术类型的构造方式,编译器同样无法做出决定。

可以发现的是,在上述的类型转换过程中,编译器是通过最佳匹配的方式来选择合适的转换方式的。由于上述的转换都是处于算术类型转换的等级,因此编译器无法识别哪种方式更匹配。只要转换方式中存在不同的转换等级,那么二义性就不会发生。比如:

short s = 42;
// promoting short to int is better than converting short to double
A a3(s);  // uses A::A(int)
这里的参数类型是 short。 在转换的过程中,shortint 发生了整型提升的类型转换。这一类型转换的等级高于算术类型的转换,因此接收 int 的构造函数是最优选择。

当存在多个用户自定义的转换时,如果转换之前 / 之后存在标准类型的转换,则使用最佳匹配的策略来决定使用哪个转换。

正确设计自定义类型转换的几个准则:

  1. 不要定义类之间的交互转换。
  2. 避免到算术类型的类型转换。特别是如果已经定义了一个到算术类型的转换:
    1. 不要定义接收其他算术类型的构造函数
    2. 不要重载到别的算术类型的转换运算符
    3. 使用已经定义的转换,以及算术类型自身的标准转换来得到最终的结果

总而言之,除了 bool 类型的显式转换以外,我们应该尽量避免自定义类型转换,并且尽可能的限制隐式转换在转换构造函数中的使用。

重载函数与转换构造函数

除了以上的两种情况以外,如果不同的类中同时定义了到某个目标类型的转换,那么这些转换会被视为具有同等的优先级。比如下面的例子:

struct C {
    C(int);
    // other members
};
struct D {
    D(int);
    // other members
};
void manip(const C&);
void manip(const D&);
manip(10); // error ambiguous: manip(C(10)) or manip(D(10))
在这个例子中,由于 C 和 D 都定义了转换到 int 的类型转换,因此在调用的时候,编译器无法确定使用哪一个转换来得到 int 参数。这种情况下只能通过显式的指定转换方式来构造对象:
manip(C(10)); // ok: calls manip(const C&)

如果存在需要对参数进行类型转换的构造函数,那么说明程序的设计存在不足。

重载函数与用户自定义的类型转换

需要注意的是,当同时存在两个或以上的最佳自定义类型转换方案时,整个转换过程中可能会出现的标准转换不再作为选择转换方案的参考依据。具体来讲,只有当使用同一个自定义转换方案的时候,标准转换会被纳入考虑的范围(参考第二种二义性的情况)。比如下面的例子:

struct E {
    E(double);
    // other members
};
void manip2(const C&);
void manip2(const E&);
// error ambiguous: two different user-defined conversions could be used
manip2(10); // manip2(C(10) or manip2(E(double(10)))
[
这种情况下,虽然 E 默认使用 double,而 C 使用的是 int 进行构造。由于 manip2 的重载需要使用不同的自定义类型转换,此处不考虑标准转换。因此,E 和 C 都可以将 10 最终转化为对应的类对象;而这将导致编译器无法确定哪一个 manip2 版本是最佳匹配的版本。

函数匹配与运算符重载

重载的运算符实际上是重载的函数。但与一般的重载函数的匹配规则不同,当重载运算符用于表达式中,比起一般使用 () 调用函数的方式,其候选函数的范围会更大。

需要注意的是,即便运算符重载可以写成如下的函数形式:

a.operatorsym (b); // a has operatorsym as a member function
operatorsym(a, b); // operatorsym is an ordinary function
编译器也不能根据该形式的不同决定调用成员或者非成员函数。

使用类对象作为重载运算符的算子

当某个表达式中,如果参与运算的算子包含 class-type,且该 class-type 中重载了此运算,则:

  • 候选的重载函数包括:
    • 非成员函数版本的运算符
    • bulit-in 版本的运算符
  • 如果左算子是 class-type 的对象,候选函数还应该包括其自身的成员运算符版本

在使用的过程中:

  • 如果用 class-type 对象以调用(member access)的情况使用运算时,只会调用类中的成员版本
  • 如果以表达式的形式使用重载运算符,则会将运算符的成员重载与非成员重载同时纳入候选

来看下面的例子:

class SmallInt {
    friend
    SmallInt operator+(const SmallInt&, const SmallInt&);
public:
    SmallInt(int = 0);                   // conversion from int
    operator int() const { return val; } // conversion to int
private:
    std::size_t val;
};
我们对其进行如下的调用:
SmallInt s1, s2;
SmallInt s3 = s1 + s2;  // uses overloaded operator+
int i = s3 + 0;         // error: ambiguous
s3 = s1 + s2 的调用可以精确的匹配:
SmallInt operator+(const SmallInt&, const SmallInt&);
s3 + 0 的调用中:

  • 由于运算 + 同时涉及 class-type 与重载运算符,且左算子 s3 为 class-type,因此候选函数为所有的 operator+(包括成员,非成员,built-in)
  • 由于 + 没有重载 SmallIntint 的版本,因此需要将其中一个算子进行转化
  • 由于 SamllIntint 可以相互转化,因此候选函数有两种:

//non-member version, int to SmallInt
SmallInt operator+(const SmallInt&, const SmallInt&);
////built-in version, SamllInt to int
int operator(int, int);

  • 当前情况下,我们可以通过转换 s3int 类型,从而调用 Bulit-in 类型的运算符;或是通过转换 0SmallInt 类型,从而调用非成员版本的运算符。由于这两者都是最佳候选,因此我们会得到二义性错误。

如果类中同时存在 class-type 到算术类型的转换运算符的重载,那么很可能会导致重载运算符与 built-in 运算符之间的二义性。