首页 文章

运算符重载的基本规则和习惯用法是什么?

提问于
浏览
1917

注意:答案是按照特定的顺序给出的,但由于许多用户根据投票而不是给出的时间对答案进行排序,因此按照他们最有意义的顺序排列 index of the answers

(注意:这是Stack Overflow的C FAQ的一个条目 . 如果你想批评提供这种形式的常见问题解答的想法,那么发布所有这些的meta上的帖子就是这样做的地方 . 这个问题在C聊天室中受到监控,其中FAQ的想法首先出现在那里,所以你的答案很可能被那些想出这个想法的人阅读 . )

7 回答

  • 453

    Why can't operator<< function for streaming objects to std::cout or to a file be a member function?

    假设你有:

    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;
    

    这非常直观 .

  • 239

    重载新内容并删除

    注意:这只涉及重载new和delete的语法,而不是这些重载运算符的实现 . 我认为重载new和delete的语义值得他们自己的常见问题解答,在运算符重载的主题内我永远不能正义 .

    基础知识

    在C中,当您编写 new expression 之类的 new T(arg) 时,在评估此表达式时会发生两件事:首先调用 operator new 以获取原始内存,然后调用 T 的相应构造函数将此原始内存转换为有效对象 . 同样,当你删除一个对象时,首先调用它的析构函数,然后将内存返回到 operator delete .
    C允许您调整这两个操作:内存管理以及在分配的内存中构造/销毁对象 . 后者是通过为类编写构造函数和析构函数来完成的 . 微调内存管理是通过编写自己的 operator new 和_364320来完成的 .

    操作符重载的第一个基本规则 - 不要这样做 - 特别适用于重载 newdelete . 重载这些运算符的唯一原因几乎是 performance problemsmemory constraints ,并且在许多情况下,其他操作(如对所使用的算法的更改)将比尝试调整内存管理提供更多的 higher cost/gain ratio .

    C标准库附带一组预定义的 newdelete 运算符 . 最重要的是这些:

    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();
    

    前两个为对象分配/释放内存,后两个为对象数组 . 如果您提供自己的版本,他们将 not overload, but replace 标准库中的那些 .
    如果你重载 operator new ,你应该总是重载匹配的 operator delete ,即使你从不打算调用它 . 原因是,如果构造函数在评估新表达式时抛出,则运行时系统会将内存返回到与 operator new 匹配的 operator new ,该 operator new 被调用以分配内存以创建对象 . 如果您不提供匹配 operator delete ,调用默认值,这几乎总是错误的 .
    如果重载 newdelete ,则应考虑重载数组变体 .

    新的展示位置

    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();
    

    请注意,在上面给出的placement new示例代码中,永远不会调用 operator delete ,除非X的构造函数抛出异常 .

    您还可以使用其他参数重载 newdelete . 与放置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的行为类似于static成员职能 . 对于 my_class 的对象, std::size_t 参数将始终为 sizeof(my_class) . 但是,这些运算符也会被调用 derived classes 的动态分配对象,在这种情况下,它可能会大于此 .

    全局新增和删除

    要重载全局new和delete,只需将标准库的预定义运算符替换为我们自己的运算符 . 但是,这很少需要完成 .

  • 218

    要重载的公共运算符

    超载运营商的大部分工作都是锅炉板代码 . 这并不奇怪,因为操作符只是语法糖,它们的实际工作可以通过(通常转发到)普通函数来完成 . 但重要的是你要正确使用这种锅炉板代码 . 如果您失败,您的操作员代码将无法编译,或者您的用户代码将无法编译,或者您的用户代码将出现令人惊讶的行为 .

    分配操作员

    关于作业有很多话要说 . 但是,大部分内容已在GMan's famous Copy-And-Swap FAQ中说过了,所以我将在这里跳过大部分内容,仅列出完美的赋值运算符以供参考:

    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>> 时,只有在读取本身成功时才需要手动设置流的状态,但结果不是预期的结果 .

    函数调用运算符

    用于创建函数对象的函数调用操作符(也称为函子)必须定义为 member 函数,因此它始终具有成员函数的隐式 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另请注意,后缀变体功能更多,因此使用效率低于前缀变量 . 这是一个很好的理由通常更喜欢前缀增量而不是后缀增量 . 虽然编译器通常可以优化内置类型的后缀增量的额外工作,但它们可能无法对用户定义的类型执行相同的操作(这可能是像列表迭代器那样天真地看起来的东西) . 一旦你习惯了我,当我不是一个内置类型(加上你必须在更改类型时改变代码)时,我很难记得做到这一点,所以最好做一个除非明确需要postfix,否则总是使用前缀增量的习惯 .

    二进制算术运算符

    对于二进制算术运算符,不要忘记遵守第三个基本规则运算符重载:如果提供 + ,也提供 += ,如果提供 - ,则不要省略 -= 等 . 据说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+ 采用其左操作数 by copy 而不是const引用 . 其原因与 operator= 每个副本的参数相同的原因相同 .

    位操作运算符 ~ & | ^ << >> 应该以与算术运算符相同的方式实现 . 但是,(除了输出和输入的重载 <<>> 之外)很少有合理的用例来重载这些 .

    3同样,从中可以得出的教训是a = b通常比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_typeclass (或 structunion )类型,则递归调用另一个 operator->() ,直到 operator->() 返回非类类型的值 .

    一元地址运算符永远不应该重载 .

    对于 operator->*() ,请参阅this question . 它很少使用,因此很少超载 . 实际上,即使是迭代器也不会使它超载 .


    继续Conversion Operators

  • 148

    C中运算符重载的三个基本规则

    当涉及到C中的运算符重载时,有 three basic rules you should follow . 与所有这些规则一样,确实有例外 . 有时人们偏离了它们,结果并不是错误的代码,但这种积极的偏差很少而且很远 . 至少,我所看到的100个这样的偏差中有99个是没有道理的 . 然而,它可能也是1000中的999.所以你最好坚持以下规则 .

    • Whenever the meaning of an operator is not obviously clear and undisputed, it should not be overloaded. 相反,提供一个具有良好选择名称的函数 .
      基本上,重载运营商的第一个也是最重要的规则是:不要这样做 . 这可能看起来很奇怪,因为有很多关于运算符重载的知识,因此很多文章,书籍章节和其他文本都涉及到这一切 . 但是,尽管有这些看似明显的证据,但只有极少数情况下运营商超载是合适的 . 原因是实际上很难理解运算符应用背后的语义,除非在应用程序域中使用运算符是众所周知且无可争议的 . 与流行的看法相反,情况并非如此 .

    • Always stick to the operator’s well-known semantics.
      C对重载运算符的语义没有限制 . 您的编译器将很乐意接受实现二进制 + 运算符的代码,以从其右操作数中减去 . 但是,这样的运算符的用户永远不会怀疑表达式 a + bb 中减去 a . 当然,这假设应用程序域中的运算符的语义是无可争议的 .

    • Always provide all out of a set of related operations.
      运营商彼此之间以及与其他运营相关 . 如果您的类型支持 a + b ,则用户也可以调用 a += b . 如果它支持前缀增量 ++a ,它们也会期望 a++ 也能正常工作 . 如果他们可以检查 a < b ,他们肯定会期望能够检查 a > b . 如果他们可以复制 - 构建您的类型,他们希望分配也可以工作 .


    继续The Decision between Member and Non-member .

  • 32

    会员与非会员之间的决定

    二元运算符 = (赋值), [] (数组预订), -> (成员访问)以及n-ary () (函数调用)运算符必须始终实现为 member functions ,因为语言的语法要求它们 .

    其他运营商可以作为成员或非成员实施 . 但是,其中一些通常必须作为非成员函数实现,因为它们的左操作数不能被您修改 . 其中最突出的是输入和输出运算符 <<>> ,其左操作数是标准库中的流类,您无法更改 .

    对于您必须选择将它们实现为成员函数或非成员函数的所有运算符, use the following rules of thumb 来决定:

    • 如果是 unary operator ,请将其实现为 member 函数 .

    • 如果二元运算符处理 both operands equally (它保持不变),则将此运算符实现为 non-member 函数 .

    • 如果二元运算符确实 not 处理它的两个操作数 equally (通常它会改变它的左操作数),如果它必须访问操作数的私有部分,那么使它成为左操作数类型的 member 函数可能是有用的 .

    当然,与所有经验法则一样,也有例外 . 如果你有类型

    enum Month {Jan, Feb, ..., Nov, Dec}
    

    并且您希望为其重载递增和递减运算符,您不能将其作为成员函数执行,因为在C中,枚举类型不能具有成员函数 . 所以你必须将它作为一个自由函数重载 . 对于嵌套在类模板中的类模板, operator<() 在类定义中作为成员函数内联完成时更容易编写和读取 . 但这些确实是罕见的例外 .

    (但是,如果你做了一个例外,不要忘记操作数的 const -ness问题,对于成员函数,成为隐式 this 参数 . 如果作为非成员函数的运算符将其最左边的参数作为一个 const 引用,与成员函数相同的运算符需要在末尾有 const 才能使 *this 成为 const 引用 . )


    继续Common operators to overload .

  • 136

    C中运算符重载的通用语法

    您无法在C中更改内置类型的运算符的含义,只能为用户定义的类型1重载运算符 . 也就是说,至少一个操作数必须是用户定义的类型 . 与其他重载函数一样,运算符只能为一组参数重载一次 .

    并非所有运算符都可以在C中过载 . 无法重载的运算符包括: . :: sizeof typeid .* 和C中唯一的三元运算符 ?:

    可以在C中重载的运算符包括:

    • 算术运算符: + - * / %+= -= *= /= %= (所有二进制中缀); + - (一元前缀); ++ -- (一元前缀和后缀)

    • 位操作: & | ^ << >>&= |= ^= <<= >>= (所有二进制中缀); ~ (一元前缀)

    • 布尔代数: == != < > <= >= || && (所有二进制中缀); ! (一元前缀)

    • 内存管理: new new[] delete delete[]

    • 隐式转换运算符

    • miscellany: = [] -> ->* , (所有二进制中缀); * & (所有一元前缀) () (函数调用,n-ary中缀)

    但是,您可以重载所有这些并不意味着您应该这样做 . 请参阅运算符重载的基本规则 .

    在C中,运算符以 functions with special names 的形式重载 . 与其他函数一样,重载运算符通常可以实现为 member function of their left operand's typenon-member functions . 是否可以自由选择或绑定使用其中任何一个取决于几个标准 . 应用于对象x的一元运算符 @ 3被调用为 operator@(x)x.operator@() . 应用于对象 xy 的二进制中缀运算符 @ 被称为 operator@(x,y)x.operator@(y) .4

    实现为非成员函数的运算符有时是其操作数类型的朋友 .

    1“用户定义”一词可能略有误导 . C区分内置类型和用户定义类型 . 前者属于例如int,char和double;后者属于所有struct,class,union和enum类型,包括来自标准库的类型,即使它们不是由用户定义的 .

    2本常见问题解答的后续部分将对此进行介绍 .

    3 @在C中不是有效的运算符,这就是我将它用作占位符的原因 .

    4 C中唯一的三元运算符不能过载,唯一的n元运算符必须始终作为成员函数实现 .


    继续The Three Basic Rules of Operator Overloading in C++ .

  • 936

    转换运算符(也称为用户定义的转换)

    在C中,您可以创建转换运算符,这些运算符允许编译器在您的类型和其他已定义类型之间进行转换 . 有两种类型的转换运算符,隐式和显式运算符 .

    隐式转换运算符(C 98 / C 03和C 11)

    隐式转换运算符允许编译器将用户定义类型的值隐式转换(如 intlong 之间的转换)到其他类型 .

    以下是一个带隐式转换运算符的简单类:

    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() 不是lvalue,因此第一个不匹配:

    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: In function ‘int main()’:
    prog.cpp:15:18: error: no matching function for call to ‘f(my_string)’
    prog.cpp:15:18: note: candidates are:
    prog.cpp:11:10: note: void f(my_string&)
    prog.cpp:11:10: note:   no known conversion for argument 1 from ‘my_string’ to ‘my_string&’
    prog.cpp:12:10: note: void f(const char*)
    prog.cpp:12:10: note:   no known conversion for argument 1 from ‘my_string’ to ‘const char*’
    

    要调用显式强制转换运算符,必须使用 static_cast ,C样式强制转换或构造函数样式转换(即 T(value) ) .

    但是,有一个例外:允许编译器隐式转换为 bool . 此外,在转换为 bool 之后,不允许编译器执行另一个隐式转换(允许编译器一次执行2次隐式转换,但最多只能执行1次用户定义的转换) .

    因为编译器不会强制转换"past" bool ,所以显式转换运算符现在不再需要Safe Bool idiom . 例如,C 11之前的智能指针使用Safe Bool习惯用法来防止转换为整数类型 . 在C 11中,智能指针使用显式运算符,因为在将类型显式转换为bool之后,不允许编译器隐式转换为整数类型 .

    继续Overloading new and delete .

相关问题