常见的运算符过载
超载运营商的大部分工作都是锅炉板代码。这并不奇怪,因为操作符只是语法糖,它们的实际工作可以通过(通常转发到)普通函数来完成。但重要的是你要正确使用这种锅炉板代码。如果您失败,您的操作员代码将无法编译,或者您的用户代码将无法编译,或者您的用户代码将出现令人惊讶的行为。
分配操作员
关于作业有很多话要说。但是,大部分内容已经在说过了 GMan着名的Copy-And-Swap常见问题解答,所以我将在这里跳过大部分内容,仅列出完美的赋值运算符以供参考:
X& X::operator=(X rhs)
{
swap(rhs);
return *this;
}
Bitshift运算符(用于流I / O)
bitshift运算符 <<
和 >>
尽管仍然在硬件接口中用于从C继承的位操作函数,但在大多数应用程序中,它们作为重载流输入和输出运算符变得更加普遍。有关作为位操作运算符的指导重载,请参阅下面的二进制算术运算符部分。要在对象与iostream一起使用时实现自己的自定义格式和解析逻辑,请继续。
流运算符(最常见的是重载运算符)是二进制中缀运算符,其语法对它们应该是成员还是非成员没有限制。
由于它们改变了左参数(它们改变了流的状态),根据经验法则,它们应该被实现为左操作数类型的成员。但是,它们的左操作数是来自标准库的流,虽然标准库定义的大多数流输出和输入操作符确实被定义为流类的成员,但当您为自己的类型实现输出和输入操作时,无法更改标准库的流类型。这就是为什么你需要为你自己的类型实现这些运算符作为非成员函数。
两者的规范形式是:
std::ostream& operator<<(std::ostream& os, const T& obj)
{
// write obj to stream
return os;
}
std::istream& operator>>(std::istream& is, T& obj)
{
// read obj from stream
if( /* no valid object of T found in stream */ )
is.setstate(std::ios::failbit);
return is;
}
实施时 operator>>
,只有在读取本身成功时才需要手动设置流的状态,但结果不是预期的结果。
函数调用运算符
用于创建函数对象的函数调用运算符(也称为函子)必须定义为a 会员 函数,所以它总是有隐含的 this
成员职能的论点。除此之外,它可以重载以获取任意数量的附加参数,包括零。
这是一个语法示例:
class foo {
public:
// Overloaded call operator
int operator()(const std::string& y) {
// ...
}
};
用法:
foo f;
int a = f("hello");
在整个C ++标准库中,始终复制函数对象。因此,您自己的功能对象应该便宜复制。如果函数对象绝对需要使用复制成本高昂的数据,最好将该数据存储在其他地方并让函数对象引用它。
比较运算符
根据经验法则,二进制中缀比较运算符应实现为非成员函数1。一元前缀否定 !
应该(根据相同的规则)实现为成员函数。 (但重载它通常不是一个好主意。)
标准库的算法(例如 std::sort()
)和类型(例如 std::map
)永远只会期待 operator<
在场。但是,那 您的类型的用户将期望所有其他运营商出现,所以,如果你定义 operator<
,一定要遵循运算符重载的第三个基本规则,并定义所有其他布尔比较运算符。实现它们的规范方法是:
inline bool operator==(const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator!=(const X& lhs, const X& rhs){return !operator==(lhs,rhs);}
inline bool operator< (const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator> (const X& lhs, const X& rhs){return operator< (rhs,lhs);}
inline bool operator<=(const X& lhs, const X& rhs){return !operator> (lhs,rhs);}
inline bool operator>=(const X& lhs, const X& rhs){return !operator< (lhs,rhs);}
这里需要注意的重要事项是,这些操作符中只有两个实际执行任何操作,其他操作符只是将其参数转发给这两个操作符中的任何一个来完成实际操作。
重载剩余的二进制布尔运算符的语法(||
, &&
)遵循比较运算符的规则。但是,确实如此 非常 你不太可能找到合理的用例2。
1 与所有的经验法则一样,有时也可能有理由打破这一个。如果是这样,不要忘记二进制比较运算符的左侧操作数,对于成员函数将是 *this
, 需要是 const
也是。因此,作为成员函数实现的比较运算符必须具有以下签名:
bool operator<(const X& rhs) const { /* do actual comparison with *this */ }
(注意 const
最后。)
2 应该注意的是内置版本 ||
和 &&
使用快捷语义。虽然用户定义的(因为它们是方法调用的语法糖)不使用快捷语义。用户希望这些运算符具有快捷语义,并且它们的代码可能依赖于它,因此强烈建议不要定义它们。
算术运算符
一元算术运算符
一元递增和递减运算符有前缀和后缀两种风格。为了告诉另一个,后缀变体采用额外的伪int参数。如果重载增量或减量,请确保始终实现前缀和后缀版本。
这是增量的规范实现,减量遵循相同的规则:
class X {
X& operator++()
{
// do actual increment
return *this;
}
X operator++(int)
{
X tmp(*this);
operator++();
return tmp;
}
};
请注意,后缀变体是根据前缀实现的。另请注意,postfix会额外复制。2
重载一元减号和加号不是很常见,可能最好避免。如果需要,它们可能应该作为成员函数重载。
2 另请注意,后缀变体功能更多,因此使用效率低于前缀变量。这是一个很好的理由通常更喜欢前缀增量而不是后缀增量。虽然编译器通常可以优化内置类型的后缀增量的额外工作,但它们可能无法对用户定义的类型执行相同的操作(这可能是像列表迭代器一样无辜地看起来的东西)。一旦你习惯了 i++
,记得这么做很难 ++i
相反 i
不是内置类型(在更改类型时你必须更改代码),所以最好养成一直使用前缀增量的习惯,除非明确需要后缀。
二元算术运算符
对于二进制算术运算符,不要忘记遵守第三个基本规则运算符重载:如果提供 +
,还提供 +=
,如果你提供 -
,不要省略 -=
据说Andrew Koenig是第一个观察到复合赋值算子可以作为非复合对应物的基础的人。那就是运营商 +
是以实施的方式实施的 +=
, -
是以实施的方式实施的 -=
等等
根据我们的经验法则, +
及其同伴应该是非成员,而他们的复合任务对应物(+=
改变他们的左派论点,应该是一个成员。这是示例代码 +=
和 +
,其他二进制算术运算符应以相同的方式实现:
class X {
X& operator+=(const X& rhs)
{
// actual addition of rhs to *this
return *this;
}
};
inline X operator+(X lhs, const X& rhs)
{
lhs += rhs;
return lhs;
}
operator+=
返回每个引用的结果,而 operator+
返回其结果的副本。当然,返回引用通常比返回副本更有效,但是在返回的情况下 operator+
,没有办法绕过复制。当你写作 a + b
,你希望结果是一个新值,这就是原因 operator+
必须返回一个新值。3
另请注意 operator+
采用左操作数 通过复制 而不是通过const引用。其原因与给出的理由相同 operator=
每个副本采取其论点。
位操作运算符 ~
&
|
^
<<
>>
应该以与算术运算符相同的方式实现。但是,(超载除外 <<
和 >>
对于输出和输入),很少有合理的用例来重载它们。
3 同样,从中可以得出的教训是 a += b
通常,效率高于 a + b
如果可能的话应该是首选。
数组订阅
数组下标运算符是二元运算符,必须作为类成员实现。它用于容器类型,允许通过键访问其数据元素。
提供这些的规范形式是这样的:
class X {
value_type& operator[](index_type idx);
const value_type& operator[](index_type idx) const;
// ...
};
除非您不希望您的类的用户能够更改返回的数据元素 operator[]
(在这种情况下,您可以省略非const变量),您应该始终提供运算符的两种变体。
如果已知value_type引用内置类型,则运算符的const变量应返回副本而不是const引用。
指针类型的运算符
要定义自己的迭代器或智能指针,必须重载一元前缀解引用运算符 *
和二进制中缀指针成员访问运算符 ->
:
class my_ptr {
value_type& operator*();
const value_type& operator*() const;
value_type* operator->();
const value_type* operator->() const;
};
请注意,这些也几乎总是需要const和非const版本。
为了 ->
运营商,如果 value_type
是的 class
(要么 struct
要么 union
)类型,另一个 operator->()
被称为递归,直到 operator->()
返回非类类型的值。
一元地址运算符永远不应该重载。
对于 operator->*()
看到 这个问题。它很少使用,因此很少超载。实际上,即使是迭代器也不会使它超载。
继续 转换运算符
C ++中运算符重载的三个基本规则
当谈到C ++中的运算符重载时,有 你应该遵循三个基本规则。与所有这些规则一样,确实有例外。有时人们偏离了它们,结果并不是错误的代码,但这种积极的偏差很少而且很远。至少,我所看到的100个这样的偏差中有99个是没有道理的。然而,它可能也是1000中的999.所以你最好坚持以下规则。
只要操作员的意义不明显且无可争议,它就不应该超载。 相反,提供具有精心选择的名称的功能。
基本上,重载运营商的第一个也是最重要的规则是: 不要这样做。这可能看起来很奇怪,因为有很多关于运算符重载的知识,因此很多文章,书籍章节和其他文本都涉及到这一切。尽管这看似明显的证据, 只有极少数情况下运算符重载是合适的。原因是实际上很难理解运算符应用背后的语义,除非在应用程序域中使用运算符是众所周知且无可争议的。与流行的看法相反,情况并非如此。
始终坚持运营商众所周知的语义。
C ++对重载运算符的语义没有限制。您的编译器将很乐意接受实现二进制文件的代码 +
运算符从其右操作数中减去。但是,这样的运营商的用户永远不会怀疑这种表达方式 a + b
减去 a
从 b
。当然,这假设应用程序域中的运算符的语义是无可争议的。
始终提供一系列相关操作。
运营商彼此相关以及其他业务。如果您的类型支持 a + b
,用户希望能够打电话 a += b
也是。如果它支持前缀增量 ++a
,他们会期待 a++
工作也好。如果他们可以检查是否 a < b
,他们肯定也希望能够检查是否 a > b
。如果他们可以复制构造您的类型,他们希望分配也可以工作。
继续 会员与非会员之间的决定。
C ++中运算符重载的通用语法
您无法在C ++中更改内置类型的运算符的含义,只能为用户定义的类型重载运算符1。也就是说,至少一个操作数必须是用户定义的类型。与其他重载函数一样,运算符只能为一组参数重载一次。
并非所有运算符都可以在C ++中重载。在无法超载的运营商中有: .
::
sizeof
typeid
.*
并且是C ++中唯一的三元运算符, ?:
可以在C ++中重载的运算符包括:
- 算术运算符:
+
-
*
/
%
和 +=
-=
*=
/=
%=
(所有二进制中缀); +
-
(一元前缀); ++
--
(一元前缀和后缀)
- 位操作:
&
|
^
<<
>>
和 &=
|=
^=
<<=
>>=
(所有二进制中缀); ~
(一元前缀)
- 布尔代数:
==
!=
<
>
<=
>=
||
&&
(所有二进制中缀); !
(一元前缀)
- 内存管理:
new
new[]
delete
delete[]
- 隐式转换运算符
- 杂记:
=
[]
->
->*
,
(所有二进制中缀); *
&
(所有一元前缀) ()
(函数调用,n-ary中缀)
但是,你的事实 能够 过载所有这些并不意味着你 应该 这样做。请参阅运算符重载的基本规则。
在C ++中,运算符以形式重载 具有特殊名称的函数。与其他函数一样,重载运算符通常可以实现为 左操作数类型的成员函数 或者作为 非会员职能。您是否可以自由选择或约束使用其中任何一个取决于几个标准。2 一元算子 @
3,应用于对象x,调用为 operator@(x)
或者作为 x.operator@()
。二进制中缀运算符 @
,应用于对象 x
和 y
,被称为 operator@(x,y)
或者作为 x.operator@(y)
。4
实现为非成员函数的运算符有时是其操作数类型的朋友。
1 “用户定义”一词可能略有误导。 C ++区分内置类型和用户定义类型。前者属于例如int,char和double;后者属于所有struct,class,union和enum类型,包括来自标准库的类型,即使它们不是由用户定义的。
2 这包括在内 后来的一部分 这个FAQ。
3 该 @
在C ++中不是一个有效的运算符,这就是我将它用作占位符的原因。
4 C ++中唯一的三元运算符不能重载,唯一的n-ary运算符必须始终作为成员函数实现。
继续 C ++中运算符重载的三个基本规则。
会员与非会员之间的决定
二元运算符 =
(分配), []
(数组订阅), ->
(成员访问),以及n-ary ()
(函数调用)运算符,必须始终实现为 成员职能,因为语言的语法要求他们。
其他运营商可以作为成员或非成员实施。但是,其中一些通常必须作为非成员函数实现,因为它们的左操作数不能被您修改。其中最突出的是输入和输出运算符 <<
和 >>
,其左操作数是标准库中的流类,您无法更改。
对于您必须选择将它们实现为成员函数或非成员函数的所有运算符, 使用以下经验法则 决定:
- 如果是的话 一元运算符,实现它作为一个 会员 功能。
- 如果二元运算符处理 两个操作数相同 (它保持不变),将此运算符实现为 非会员 功能。
- 如果是二元运算符 不 对待它的两个操作数 一样 (通常它会改变它的左操作数),它可能是有用的 会员 左操作数的类型的函数,如果它必须访问操作数的私有部分。
当然,与所有经验法则一样,也有例外。如果你有类型
enum Month {Jan, Feb, ..., Nov, Dec}
并且你想为它重载递增和递减运算符,你不能将它作为成员函数来执行,因为在C ++中,枚举类型不能具有成员函数。所以你必须将它作为一个自由函数重载。和 operator<()
对于嵌套在类模板中的类模板,当在类定义中作为成员函数内联完成时,更容易编写和读取。但这些确实是罕见的例外。
(然而, 如果 你做了一个例外,不要忘记这个问题 const
- 对于成员函数,操作数的含义变为隐式 this
论据。如果作为非成员函数的运算符将其最左边的参数作为a const
引用,与成员函数相同的运算符需要有一个 const
最后做 *this
一个 const
参考。)
继续 常见的运算符过载。
转换运算符(也称为用户定义的转换)
在C ++中,您可以创建转换运算符,这些运算符允许编译器在您的类型和其他已定义类型之间进行转换。有两种类型的转换运算符,隐式和显式运算符。
隐式转换运算符(C ++ 98 / C ++ 03和C ++ 11)
隐式转换运算符允许编译器隐式转换(如之间的转换) int
和 long
)用户定义类型的值对某些其他类型。
以下是一个带隐式转换运算符的简单类:
class my_string {
public:
operator const char*() const {return data_;} // This is the conversion operator
private:
const char* data_;
};
隐式转换运算符(如单参数构造函数)是用户定义的转换。在尝试匹配对重载函数的调用时,编译器将授予一个用户定义的转换。
void f(const char*);
my_string str;
f(str); // same as f( str.operator const char*() )
起初这看起来非常有用,但问题在于隐式转换甚至会在不期望的情况下启动。在以下代码中, void f(const char*)
将被召唤因为 my_string()
不是 左值,所以第一个不匹配:
void f(my_string&);
void f(const char*);
f(my_string());
初学者很容易弄错,甚至经验丰富的C ++程序员有时会感到惊讶,因为编译器选择了他们没有怀疑的过载。显式转换运算符可以减轻这些问题。
显式转换运算符(C ++ 11)
与隐式转换运算符不同,显式转换运算符在您不期望它们时将永远不会启动。以下是具有显式转换运算符的简单类:
class my_string {
public:
explicit operator const char*() const {return data_;}
private:
const char* data_;
};
请注意 explicit
。现在,当您尝试从隐式转换运算符执行意外代码时,会出现编译器错误:
prog.cpp:在函数'int main()'中:
prog.cpp:15:18:错误:调用'f(my_string)'没有匹配函数
prog.cpp:15:18:注意:候选人是:
prog.cpp:11:10:注意:void f(my_string&)
prog.cpp:11:10:注意:参数1从'my_string'到'my_string&'没有已知的转换
prog.cpp:12:10:注意:void f(const char *)
prog.cpp:12:10:注意:参数1从'my_string'到'const char *'没有已知的转换
要调用显式强制转换运算符,必须使用 static_cast
,C风格的演员表或构造函数风格的演员表(即 T(value)
)。
但是,有一个例外:允许编译器隐式转换为 bool
。此外,在转换为编译器后,不允许编译器执行另一个隐式转换 bool
(允许编译器一次执行2次隐式转换,但最多只能执行1次用户定义的转换)。
因为编译器不会抛出“过去” bool
,显式转换运算符现在删除了对的需要 安全布尔成语。例如,C ++ 11之前的智能指针使用Safe Bool习惯用法来防止转换为整数类型。在C ++ 11中,智能指针使用显式运算符,因为在将类型显式转换为bool之后,不允许编译器隐式转换为整数类型。
继续 超载 new
和 delete
。
超载 new
和 delete
注意: 这只涉及到 句法 超载 new
和 delete
,而不是 履行 这样的重载运算符。我认为重载的语义 new
和 delete
值得拥有自己的FAQ在运算符重载的主题内,我永远不能正义。
基本
在C ++中,当你写一个 新的表达 喜欢 new T(arg)
评估此表达式时会发生两件事:第一 operator new
被调用以获取原始内存,然后是相应的构造函数 T
被调用以将此原始内存转换为有效对象。同样,当你删除一个对象时,首先调用它的析构函数,然后返回内存 operator delete
。
C ++允许您调整这两个操作:内存管理以及在分配的内存中构造/销毁对象。后者是通过为类编写构造函数和析构函数来完成的。微调内存管理是通过编写自己的内容来完成的 operator new
和 operator delete
。
运算符重载的第一个基本规则 - 不要这样做 - 特别适用于超载 new
和 delete
。几乎是使这些运算符超载的唯一原因是 性能问题 和 记忆约束,在许多情况下,其他行动,如 更改算法 用过,会提供很多 更高的成本/收益率 而不是试图调整内存管理。
C ++标准库附带一组预定义的 new
和 delete
运营商。最重要的是这些:
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void*) throw();
void* operator new[](std::size_t) throw(std::bad_alloc);
void operator delete[](void*) throw();
前两个为对象分配/释放内存,后两个为对象数组。如果您提供自己的版本,他们会 不过载,但更换 来自标准库的那些。
如果你超载 operator new
,你应该总是重载匹配 operator delete
即使你从不打算打电话给它。原因是,如果构造函数在评估新表达式时抛出,则运行时系统会将内存返回到 operator delete
匹配 operator new
被调用来分配内存以创建对象。如果没有提供匹配 operator delete
,默认的一个被调用,这几乎总是错误的。
如果你超载 new
和 delete
,你也应该考虑重载数组变体。
放置 new
C ++允许new和delete运算符采用其他参数。
所谓的placement new允许您在某个地址创建一个对象,该地址传递给:
class X { /* ... */ };
char buffer[ sizeof(X) ];
void f()
{
X* p = new(buffer) X(/*...*/);
// ...
p->~X(); // call destructor
}
标准库附带了new和delete运算符的相应重载:
void* operator new(std::size_t,void* p) throw(std::bad_alloc);
void operator delete(void* p,void*) throw();
void* operator new[](std::size_t,void* p) throw(std::bad_alloc);
void operator delete[](void* p,void*) throw();
请注意,在上面给出的新安置示例代码中, operator delete
永远不会被调用,除非X的构造函数抛出异常。
你也可以超载 new
和 delete
与其他论点。与放置new的附加参数一样,这些参数也列在关键字后面的括号内 new
。仅仅由于历史原因,这些变体通常也称为放置新的,即使它们的参数不是用于将对象放置在特定地址。
特定于类的新建和删除
最常见的是,您需要微调内存管理,因为测量已经显示特定类或一组相关类的实例经常被创建和销毁,并且运行时系统的默认内存管理已经调整为一般表现,在这种特定情况下效率低下。要改进这一点,您可以为特定类重载new和delete:
class my_class {
public:
// ...
void* operator new();
void operator delete(void*,std::size_t);
void* operator new[](size_t);
void operator delete[](void*,std::size_t);
// ...
};
因此重载,new和delete的行为类似于静态成员函数。对象 my_class
, std::size_t
争论永远是 sizeof(my_class)
。但是,这些运算符也被称为动态分配的对象 派生类,在这种情况下,它可能大于那个。
全局新增和删除
要重载全局new和delete,只需将标准库的预定义运算符替换为我们自己的运算符。但是,这很少需要完成。
为什么不能 operator<<
流对象的功能 std::cout
或者文件是会员功能?
假设你有:
struct Foo
{
int a;
double b;
std::ostream& operator<<(std::ostream& out) const
{
return out << a << " " << b;
}
};
鉴于此,你不能使用:
Foo f = {10, 20.0};
std::cout << f;
以来 operator<<
作为成员函数重载 Foo
,运营商的LHS必须是 Foo
目的。这意味着,您将被要求使用:
Foo f = {10, 20.0};
f << std::cout
这是非常不直观的。
如果将其定义为非成员函数,
struct Foo
{
int a;
double b;
};
std::ostream& operator<<(std::ostream& out, Foo const& f)
{
return out << f.a << " " << f.b;
}
你将能够使用:
Foo f = {10, 20.0};
std::cout << f;
这非常直观。