前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《C++Primer》第十四章 重载运算与类型转换

《C++Primer》第十四章 重载运算与类型转换

作者头像
TOMOCAT
发布2020-11-24 14:44:24
8680
发布2020-11-24 14:44:24
举报

第十四章 重载运算与类型转换

基本概念

  • 当一个重载符是成员函数时,this绑定到左侧运算对象,成员运算符的(显式)参数数量比运算对象的数量少一个
  • 运算符函数必须要么是类的成员,要么最少含有一个类类型的参数,不能为int重定义内置的运算符
  • 我们只能重载已有的运算符,而不能发明新的运算符
  • 我们可以冲在大部分的运算符,但是::.*.?:这四个运算符是不能被重载的
  • 使用重载的运算符本质上是一次函数调用,关于运算对象求值顺序和短路求值属性无法保留下来,因此不建议重载逻辑与/或运算符和逗号运算符
  • C++定义了逗号运算符和取地址运算符用于类类型对象时的特殊含义,所以我们一般也不重载它们

一般情况下不重载逗号、取地址、逻辑与和逻辑或运算符

我们定义重载的运算符时,必须首先决定它是声明为类的成员函数还是声明为一个普通的非成员函数:

  • 赋值=、下标[]、调用()和成员访问箭头->运算符必须是成员
  • 复合赋值运算符一般来说应该是成员,但并非必须
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,一般是成员呢
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数

输入和输出运算符

IO库分别使用>><<执行输入和输出操作,并定义了其读写内置类型的版本,而类需要自定义适合其对象的新版本呢以支持IO操作。

1. 重载输出运算符<<

通常情况下,输出运算符的第一个形参是非常量ostream对象的引用。因为向流写入内容会改变其状态所以不能是常量,另外该形参是引用时因为我们无法直接复制一个ostream对象。第二个形参一般是一个常量引用。引用的原因是我们希望避免复制形参,常量是因为打印对象不会改变对象的内容。并且为了和其他输出运算符保持一致,operator<<一般要返回它的ostream形参。

需要注意以下几点:

  • 输出运算符尽量避免格式化操作,尤其不要打印换行符
  • 输入输出运算符必须是非成员函数,IO运算符通常需要读写类的非公有数据成员,所以IO运算符一般被声明为友元
2. 重载输入运算符>>

通常情况下,输入运算符的一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。

istream &operator>>(istream &is, Sales_data &item)
{
    double price; // 不需要初始化,因为我们将先读入数据到price,之后才使用它
    is >> item.bookNo >> item.units_sold >> price;
    if (is) // 检查输入是否成功
        item.revenue = item.units_sold * price;
    else
        item = Sales_data(); // 输入失败:对象被赋予默认的状态
    return is;
}

if语句检查读取操作是否成功,如果发生了IO错误,则运算符将给定的对象重置为空Sales_data,这样可以确保对象处于正确的状态。

在执行输入运算符时可能发生下列错误:

  • 当流含有错误类型的数据时读取操作可能失败,例如输入运算符假定接下来读入的是两个数字数据,但是输入的不是数字数据,则读取数据及后续对流的其他使用都将失败
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败

算术和关系运算符

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

Sales_data operator +(const Sales_data &lhs, const Sales_data &rhs)
{
    Sales_data sum = lhs;
    sum += rhs;
    return sum;
}

注意:

  • 一般将累加的值放到一个局部变量,操作完成后返回该局部变量的副本作为结果
  • 如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符
1. 相等运算符
  • 如果有一个类含有判断两个对象是否相等的操作,那么它应该把函数定义成operator==而非一个普通的命名函数,这样用户无须再费时费力去学习并记忆一个全新的函数名字
  • 如果类定义了operator==,那么该运算符也应该能判断一组给定的对象中是否含有重复数据
  • 相等运算应该具有传递性,比如a==bb==c,那么我们能推出a==c
  • 如果类定义了operator==,那么也应该定义operator!=
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,另一个只是调用真正工作的运算符
2. 关系运算符

通常情况下,关系运算符应该:

  • 定义顺序关系,令其与关联容器中对关键字的要求一致
  • 如果类同时也有==运算符,则定义一种关系令其与==保持一致,特别是如果两个对象是!=的,那么一个对象应该<另一个

赋值运算符

我们之前定义过拷贝赋值和移动赋值运算符,它们可以把类的一个对象赋值给该类的另一个对象。类还可以定义其他赋值运算符使用别的类作为右侧运算对象。比如:

vector<string> v;
v = {"a", "an", "the"};

// 把这个运算符添加到StrVec中
StrVec &StrVec::operator=(initializer_list<string> i1)
{
    // alloc_n_copy分配内存空间并从给定范围内拷贝元素
    auto data = alloc_n_copy(i1.begin(), i1.end());
    free();
    elements = data.first;
    first_free = cap = data.second;
    return *this;
}

我们可以重载赋值运算符,但是无论形参是什么,赋值运算符都必须被定义为成员函数。

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

// 作为成员的二元运算符:左侧运算对象绑定到隐式的this指针
Sales_data& Sales_data::operator+=(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;
    revenue += rhs.revenue;
    return *this;
}

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

下标运算符

表示容器的类可以通过元素在容器中的位置访问元素,这些类一般会定义下标运算符operator[]

下标运算符必须是成员函数。

与下标的原始定义兼容,我们需要确保:

  • 下表运算符通常以所访问元素的引用作为返回值,这样下标可以出现在赋值运算符的任意一端
  • 最好定义下标运算符的常量和非常量版本,当作用于一个常量对象时下标运算符返回常量引用以确保我们不会给返回的对象赋值

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

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;
};

递增和递减运算符

定义递增和递减运算符的类应该同时定义前置版本和后置版本,这些运算符通常应该被定义为类的成员。同时为了保持与内置版本一致,前置运算符应该返回递增或者递减后对象的引用。

// 前置版本:返回递增/递减对象的引用
StrBlobPtr& StrBlobPtr::operator++()
{
    // 如果curr已经指向了容器的尾后位置,则无法递增它
    check(curr, "increment past end of StrBlobPtr");
    ++curr;  // 将curr在当前状态下向前移动一个元素
    return *this;
}
StrBolbPtr& StrBlobPtr::operator--()
{
    // 如果curr是0, 那么继续递减它将产生一个无效下标
    --curr;
    check(curr, "decrement past begin of StrBlobPtr");
    return *this;
}

后置版本接收一个额外的(不被使用的)int类型的形参,当我们使用后置运算符时,编译器为这个形参提供一个值为0的形参。

// 后置版本:递增/递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int)
{
    // 此处无须检查有效性,调用前置递增运算才需要检查
    StrBlobPtr ret = *this; // 记录当前的值
    ++*this; // 向前移动一个元素,前置++会检查递增的有效性
    return ret; // 返回之前记录的状态
}
StrBolbPtr StrBlobPtr::operator--(int)
{
    // 此处无须检查有效性, 调用前置递减运算时才需要检查
    StrBlobPtr ret = *this; // 记录当前的值
    --*this;
    return ret;  // 返回之前记录的状态
}

成员访问运算符

在迭代器和智能指针类中常常用到解引用运算符*和箭头运算符-

class StrBlobPtr {
public:
    std::string& operator*() const
    {
        auto p = check(curr, "deference past end");
        return (*p)[curr]; // (*p)是对象所指的vector
    }
    std::string* operator->() const
    {
        // 将实际工作委托给解引用运算符
        return & this->operator*();
    }
}

箭头运算符必须是类的成员呢,解引用运算符往往也是类的成员。

对于形如point->mem的表达式来说,point必须是指向类的对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem分别等价于:

  • (*point).mempoint是一个内置的指针类型
  • point.operator()->mempoint是类的一个对象

函数调用运算符

如果类重载了函数调用运算符,那么我们可以像使用函数一样使用该类的对象,因为这样的类同时也能存储状态,所以与普通函数相比它们更加灵活。

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

// 调用
absInt absObj;
int ui = absObj(-42);

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

函数对象常常作为泛型算法的实参,比如可以使用for_each算法和我们的PrintString类来打印容器的内容:

class PrintString {
public:
    PrintString(osttream &o = cout, char c = ' '):
        os(o), sep(c) { }
    void operator()(const string &s) const { os << s << sep; }
private:
    ostream &os;   // 用于写入的目的流
    char sep;      // 用于将不同输出隔开的字符
};

// 调用
PrintString printer; // 使用默认值,打印到cout
printer(s);          // 在cout中打印s, 后面跟一个空格
PrintString errors(cerr, '\n');
errors(s);           // 在cerr中打印s, 后面跟一个换行符

使用标准库for_each算法和我们的PrintString类来打印容器的内容:

for_each(vs.begin(), vs.end(), PrintString(cerr, '\n'));
1. lambda是函数对象

当我们编写了一个lambda后,编译器将其翻译成一个未命名类的未命名对象。默认情况下lambda不能改变它捕获的变量,因此在默认情况下由lambda产生的类当中的调用运算符是一个const成员函数,如果lambda被声明为可变的,那么调用运算符就不是const的了。 当一个lambda表达式通过引用捕获变量时,将由程序负责确保lambda执行时引用的对象却是存在,因此编译器可以直接使用该引用而无需在lambda产生的类中将其存储为数据成员。如果通过值捕获的变量被拷贝到lambda中,因此这种lambda产生的类必须为每个值捕获的变量建立对应的数据成员,同时创建构造函数,令其使用捕获的变量来初始化数据成员。

2. 标准库定义的函数对象

算术:

  • plus<Type>
  • minus<Type>
  • multiplies<Type>
  • divides<Type>
  • modules<Type>
  • negate<Type> 关系:
  • equal_to<Type>
  • not_equal_to<Type>
  • greater<Type>
  • greater_equal<Type>
  • less<Type>
  • less_qual<Type> 逻辑:
  • logical_and<Type>
  • logical_or<Type>
  • logical_nnot<Type>
3. 可调用对象与function

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

3.1 标准库function类型

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

  • function<T> ff是一个用来存储可调用对象的空function,这些可调用对象的调用形式应该与函数类型T相同
  • function<T> f(nullptr):显式地构造一个空funciton
  • 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_atgument_typesecond_argument_type分别代表两个实参的类型

通过function声明一个函数类型:

function<int(int, int)>; // 声明一个function类型,表示接受两个int返回一个int的可调用对象

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

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

// 列举了可调用对象与二元运算符对应关系的表格
// 可调用对象需要接收两个int,返回一个int
// 其中的元素可以是函数指针、函数对象或者lambda
map<string, function<int(int, int)>> binops;

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

// 调用方式
binops["+"](10, 5);  // 调用add(10, 5)
binops["-"](10, 5);  // 调用minus<int>对象的调用运算符
binops["/"](10, 5);  // 调用divide对象的调用运算符
binops["*"](10, 5);  // 调用lambda对象
binops["%"](10, 5);  // 调用lambda对象
3.2 重载的函数与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}); // 错误:哪个add?

解决上述二义性的一种方法是存储函数指针:

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

我们也可以通过lambda消除二义性:

binops.insert({"+", [](int a, int b){return add(a, b);}});

新版本标准库中function类与旧版本中的unnary_functionbinary_function没有关联,后两个类已经被更通用的bind函数替代了。

重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换拿class-type conversions,这样的转换有时也被称为用户定义的类型转换user-defined conversion

1. 类型转换运算符

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

operator type() const;

其中type表示某种类型,类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针以及函数指针)或者引用类型。类型转换运算符既没有显式的返回类型,也没有形参,而且必须定义成类的成员函数。类型转换运算符通常不应该改变待转换对象的内容,所以一般被定义为const成员。

我们定义一个表示0~255之间一个整数的一个类:

// 构造函数将算数类型的值转换成SmallInt对象,而类型转换运算符将SmallInt对象转化成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;
};

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

在实践中类很少提供类型转换运算符,在大多数情况下,如果类型转换自动发生,用户可能会感觉比较意外,而不是感觉受到了帮助。然而这条经验法则存在一种例外情况:对于类来说,定义向bool的类型转换还是比较普遍的现象。但是这种类型转换可能引发意想不到的结果,特别是当istream含有向bool的类型转换时,下面的代码仍然编译通过:

int i = 42;
cin << i; // 如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的

这段程序试图将输出运算符作用于输入流,因为istream本身并没有定义<<,所以本来这段代码应该产生错误。然而该代码能使用istreambool类型转换运算符将cin转换为bool,而这个bool值将会被提升为int并用作内置的左移运算符的左侧运算对象。这样一来提升后的bool1/0会被左移42个位置。

为了防止这样的异常发生,C++新标准引入了显式的类型你转换运算符:

class SmallInt {
public:
    // 编译器不会自动执行这一类型转换
    explicit operator int() const { return val; }
    // ...其他成员
}

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

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

如果类中包含哪一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则我们编写的代码将很可能会具有二义性。两种情况下可能存在多重转换路径:

  • 第一种情况是两个类提供相同的类型转换:例如A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换
  • 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身就可以通过其他类型转换联系在一起。最典型的例子就是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型相关的转换规则

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

// 最好不要在两个类之间构建相同的类型转换
struct B;
struct A {
    A() = dufault;
    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&))

// 如果确实想执行上述的调用,那么就不得不显式地调用类型转换运算符或者转换构造函数
A a1 = f(b.operator A()); // 正确:使用B的类型转换运算符
A a2 = f(A(b)); // 正确:使用A的构造函数

函数匹配与重载运算符

如果a是一种类类型,那么表达式a sym b可能是如下两种:

a.operatorsym(b); // a有一个operatorsym成员函数
operatorsym(a, b); // operatorsym是一个普通函数

这意味着表达式中运算符的候选函数集既应该包含成员函数,也应该包含非成员函数。

举个例子,我们为SmallInt类定义一个加法运算符:

class SmallInt {
    friend SmallInt operator+(const SmallInt&, const SmallInt&);
public:
    SmallInt(int = 0); // 转换源为int的类型转换
    operator int() const { return val; } // 转换目标为int的类型转换
private:
    std::size_t val;
};

我们可以将两个SmallInt对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:

SmallInt s1, s2;
SmallInt s3 = s1 + s2; // 使用重载的operator
int i = s3 + 0; // 二义性错误:既可以把0转换成SmallInt,然后使用SmallInt的+;也可以将s3转换成int,对int执行内置的加法运算

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 第十四章 重载运算与类型转换
    • 基本概念
      • 输入和输出运算符
        • 算术和关系运算符
          • 赋值运算符
            • 下标运算符
              • 递增和递减运算符
                • 成员访问运算符
                  • 函数调用运算符
                    • 重载、类型转换与运算符
                      • 函数匹配与重载运算符
                      相关产品与服务
                      容器服务
                      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档