【问题标题】:Are there better ways to overload ostream operator<<?是否有更好的方法来重载 ostream 运算符<<?
【发布时间】:2019-12-21 17:52:42
【问题描述】:

假设你有以下代码:

#include <iostream>

template <typename T>
class Example
{
  public:
    Example() = default;
    Example(const T &_first_ele, const T &_second_ele) : first_(_first_ele), second_(_second_ele) { }

    friend std::ostream &operator<<(std::ostream &os, const Example &a)
    {
      return (os << a.first_ << " " << a.second_);
    }

  private:
    T first_;
    T second_;
};

int main()
{
  Example example_(3.45, 24.6); // Example<double> till C++14
  std::cout << example_ << "\n";
}

这是重载operator&lt;&lt; 的唯一方法吗?

friend std::ostream &operator<<(std::ostream &os, const Example &a)
{
  return (os << a.first_ << " " << a.second_);
}

就性能而言,这是重载它的最佳方式还是有更好的选择来执行此实现?

【问题讨论】:

  • 您认为有更好的选择吗?想想它在做什么,并询问你不需要哪些部分。有吗?
  • 那会有什么不同?它会是什么样子?试一试,看看它是否更快。
  • 您有什么性能问题?外部 I/O 的成本通常比您的代码高得多,如果不是这样,iostreams 库无论如何也不是特别快。
  • @EmanueleOggiano:“Emanuele Oggiano 正在寻找一个规范的答案。”当不清楚问题到底是什么时,很难提供一个“规范的答案”。 “重载运算符
  • 取决于您如何定义“更好” - 没有它(根据定义),不可能有规范的答案。只要类为成员提供可访问的getter,就不需要operator&lt;&lt;()friend。如果这些 getter 在代码中内联(并且实现实际上内联了这些 getter,实际上也不需要这样做 - 因为内联是对编译器的提示,而不是指令),则几乎没有可测量的差异

标签: c++ operator-overloading c++17 iostream ostream


【解决方案1】:

我相信 cmets 已经很好地回答了您的问题。从纯粹的性能角度来看,可能没有“更好”的方法来为输出流重载 &lt;&lt; 运算符,因为您的函数可能一开始就不是瓶颈。

我会建议有一种“更好”的方法来编写处理某些极端情况的函数本身。

您的&lt;&lt; 重载(现在存在)在尝试执行某些输出格式化操作时会“中断”。

std::cout << std::setw(15) << std::left << example_ << "Fin\n";

这不会左对齐您的整个 Example 输出。相反,它只左对齐first_ 成员。这是因为您一次将一个项目放入流中。 std::left 将抓取下一项左对齐,这只是你的类输出的一部分。

最简单的方法是构建一个字符串,然后将该字符串转储到您的输出流中。像这样的:

friend std::ostream &operator<<(std::ostream &os, const Example &a)
{
    std::string tmp = std::to_string(a.first_) + " " + std::to_string(a.second_);
    return (os << tmp);
}

这里有几点值得注意。首先是在这个特定示例中,您将得到尾随的 0,因为您无法控制 std::to_string() 如何格式化其值。这可能意味着编写特定类型的转换函数来为您进行任何修剪。您也可以使用std::string_views(以恢复一些效率(同样,这可能并不重要,因为函数本身可能仍然不是您的瓶颈)),但我没有使用它们的经验。

通过一次将所有对象的信息放入流中,左对齐现在将对齐对象的完整输出。

还有关于朋友与非朋友的争论。如果存在必要的吸气剂,我认为非朋友是要走的路。 Friends 很有用,但也破坏了封装,因为它们是具有特殊访问权限的非成员函数。这进入了舆论领域,但我不会编写简单的 getter,除非我觉得它们是必要的,而且我不认为 &lt;&lt; 重载是必要的。

【讨论】:

  • 如果你可以投票,你可以告诉我为什么。我不反对学习。
【解决方案2】:

据我了解,这个问题有两个模棱两可的地方:

  1. 您是否专门针对模板类。
    我会假设答案是肯定的。

  2. 是否有更好的方法来重载ostream operator&lt;&lt;(与friend-way 相比),如问题标题中所述(并假设“更好”指性能),或者有其他方式,如正文中所发布(“这是唯一的方式......”?)
    我将假设第一个,因为它包含第二个。

我设想了至少 3 种方法来重载 ostream operator&lt;&lt;

  1. friend-方式,正如您发布的那样。
  2. friend-方式,返回类型为auto
  3. friend-方式,返回类型为std::ostream

它们在底部举例说明。 我进行了几次测试。从所有这些测试中(参见下面的代码),我得出结论:

  1. 在优化模式下编译/链接(使用-O3),每个std::cout循环10000次,所有3种方法提供基本相同的性能。

  2. 在调试模式下编译/链接,没有循环

    t1 ~ 2.5-3.5 * t2
    t2 ~ 1.02-1.2 * t3
    


    即,1 比 2 和 3 慢得多,它们的性能相似。

我不知道这些结论是否适用于整个系统。 我不知道您是否会看到接近 1(最有可能)或 2(在特定条件下)的行为。


定义三个方法重载operator&lt;&lt;的代码
(我已经删除了默认构造函数,因为它们在这里无关紧要)。

方法1(如OP):

template <typename T>
class Example
{
  public:
    Example(const T &_first_ele, const T &_second_ele) : first_(_first_ele), second_(_second_ele) { }

    friend std::ostream &operator<<(std::ostream &os, const Example &a)
    {
      return (os << a.first_ << " " << a.second_);
    }

  private:
    T first_;
    T second_;
};

方法二:

template <typename T>
class Example2
{
  public:
    Example2(const T &_first_ele, const T &_second_ele) : first_(_first_ele), second_(_second_ele) { }

    void print(std::ostream &os) const
    {
        os << this->first_ << " " << this->second_;
        return;
    }

  private:
    T first_;
    T second_;
};
template<typename T>
auto operator<<(std::ostream& os, const T& a) -> decltype(a.print(os), os)
{
    a.print(os);
    return os;
}

方法三:

template <typename T>
class Example3
{
  public:
    Example3(const T &_first_ele, const T &_second_ele) : first_(_first_ele), second_(_second_ele) { }

    void print(std::ostream &os) const
    {
        os << this->first_ << " " << this->second_;
        return;
    }

  private:
    T first_;
    T second_;
};
// Note 1: If this function exists, the compiler makes it take precedence over auto... above
// If it does not exist, code compiles ok anyway and auto... above would be used
template <typename T>
std::ostream &operator<<(std::ostream &os, const Example3<T> &a)
{
    a.print(os);
    return os;
}
// Note 2: Explicit instantiation is not needed here.
//template std::ostream &operator<<(std::ostream &os, const Example3<double> &a);
//template std::ostream &operator<<(std::ostream &os, const Example3<int> &a);

用于测试性能的代码
(所有内容都放在一个单独的源文件中

#include <iostream>
#include <chrono>

在顶部):

int main()
{
    std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
    std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();
    const int nout = 10000;

    Example example_(3.45, 24.6); // Example<double> till C++14
    begin = std::chrono::steady_clock::now();
    for (int i = 0 ; i < nout ; i++ )
        std::cout << example_ << "\n";
    end = std::chrono::steady_clock::now();
    const double lapse1 = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();
    std::cout << "Time difference = " << lapse1 << "[us]" << std::endl;

    Example2 example2a_(3.5, 2.6); // Example2<double> till C++14
    begin = std::chrono::steady_clock::now();
    for (int i = 0 ; i < nout ; i++ )
        std::cout << example2a_ << "\n";
    end = std::chrono::steady_clock::now();
    const double lapse2a = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();
    std::cout << "Time difference = " << lapse2a << "[us]" << std::endl;

    Example2 example2b_(3, 2); // Example2<double> till C++14
    begin = std::chrono::steady_clock::now();
    for (int i = 0 ; i < nout ; i++ )
        std::cout << example2b_ << "\n";
    end = std::chrono::steady_clock::now();
    const double lapse2b = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();
    std::cout << "Time difference = " << lapse2b << "[us]" << std::endl;

    Example3 example3a_(3.4, 2.5); // Example3<double> till C++14
    begin = std::chrono::steady_clock::now();
    for (int i = 0 ; i < nout ; i++ )
        std::cout << example3a_ << "\n";
    end = std::chrono::steady_clock::now();
    const double lapse3a = std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count();
    std::cout << "Time difference = " << lapse3a << "[us]" << std::endl;

    std::cout << "Time difference lapse1 = " << lapse1 << "[us]" << std::endl;
    std::cout << "Time difference lapse2a = " << lapse2a << "[us]" << std::endl;
    std::cout << "Time difference lapse2b = " << lapse2b << "[us]" << std::endl;
    std::cout << "Time difference lapse3a = " << lapse3a << "[us]" << std::endl;

    return 0;
}

【讨论】:

  • 这不是一个合适的基准。 1.您需要循环迭代基准测试以避免测量缓存效果。 2. 我不知道如何获得任何有用的精确打印微秒计数。启用优化后,每次执行只需大约 1us 到 3us 左右。 3. 如果你改正了这些东西,那么还是有明显的区别,但这仅仅是因为一些测试用例需要打印更长的字符串。如果你给所有的example*_ 变量赋予相同的值,那么就没有明显的区别了。
  • godbolt.org/z/0TYZGe。具有原始值但循环并具有纳秒输出的基准位于顶部,而在所有测试中具有相同值的基准位于底部。中间和右边分别是执行的 GCC 和 Clang 生成的代码。
  • @walnut - 你是对的!考虑到评论,我修改了代码和答案。
【解决方案3】:

这是实现它的显而易见的方法。它也可能是最有效的。使用它。

【讨论】:

    【解决方案4】:

    您在问题中演示的方式是最基本的方式,在各种C++书籍中也可以找到。就我个人而言,我可能不喜欢在我的生产代码中,主要是因为:

    • 必须为每个类编写friend operator&lt;&lt; 的样板代码。
    • 添加新的类成员时,您可能还需要单独更新方法。

    自 C++14 起,我会推荐以下方式:

    图书馆

    // Add `is_iterable` trait as defined in https://stackoverflow.com/a/53967057/514235
    template<typename Derived>
    struct ostream
    {
      static std::function<std::ostream&(std::ostream&, const Derived&)> s_fOstream;
    
      static auto& Output (std::ostream& os, const char value[]) { return os << value; }
      static auto& Output (std::ostream& os, const std::string& value) { return os << value; }
      template<typename T>
      static
      std::enable_if_t<is_iterable<T>::value, std::ostream&>
      Output (std::ostream& os, const T& collection)
      {
        os << "{";
        for(const auto& value : collection)
          os << value << ", ";
        return os << "}";
      }
      template<typename T>
      static
      std::enable_if_t<not is_iterable<T>::value, std::ostream&>
      Output (std::ostream& os, const T& value) { return os << value; }
    
      template<typename T, typename... Args>
      static
      void Attach (const T& separator, const char names[], const Args&... args)
      {
        static auto ExecuteOnlyOneTime = s_fOstream =
        [&separator, names, args...] (std::ostream& os, const Derived& derived) -> std::ostream&
        {
          os << "(" << names << ") =" << separator << "(" << separator;
          int unused[] = { (Output(os, (derived.*args)) << separator, 0) ... }; (void) unused;
          return os << ")";
        };
      }
    
      friend std::ostream& operator<< (std::ostream& os, const Derived& derived)
      {
        return s_fOstream(os, derived);
      }
    };
    
    template<typename Derived>
    std::function<std::ostream&(std::ostream&, const Derived&)> ostream<Derived>::s_fOstream;
    

    用法

    为您想要operator&lt;&lt; 设施的那些类继承上述类。自动friend 将通过基础ostream 包含在这些类的定义中。所以没有额外的工作。例如

    class MyClass : public ostream<MyClass> {...};
    

    最好在它们的构造函数中,你可以Attach()要打印的成员变量。例如

    // Use better displaying with `NAMED` macro
    // Note that, content of `Attach()` will effectively execute only once per class
    MyClass () { MyClass::Attach("\n----\n", &MyClass::x, &MyClass::y); }
    

    示例

    根据您分享的内容,

    #include"Util_ostream.hpp"
    
    template<typename T>
    class Example : public ostream<Example<T>> // .... change 1
    {
    public:
      Example(const T &_first_ele, const T &_second_ele) : first_(_first_ele), second_(_second_ele)
      {
        Example::Attach(" ", &Example::first_, &Example::second_); // .... change 2
      }
    
    private:
      T first_;
      T second_;
    };
    

    Demo

    这种方法在每次打印变量时都有一个指针访问,而不是直接访问。从性能的角度来看,这种微不足道的间接性绝不应该成为代码中的瓶颈。
    出于实际目的,演示稍微复杂一些。

    要求

    • 此处的目的是提高打印变量的可读性和一致性
    • 无论继承如何,每个可打印类都应有其单独的ostream&lt;T&gt;
    • 一个对象应该定义operator&lt;&lt;或继承ostream&lt;T&gt;才能编译

    设施

    现在正在形成一个好的库组件。以下是我目前添加的附加设施。

    • 使用ATTACH()宏,我们也可以通过某种方式打印变量;通过修改库代码,始终可以根据需要自定义可变打印
    • 如果基类是可打印的,那么我们可以简单地传递一个类型转换的this;休息会得到照顾
    • 现在支持具有std::begin/end 兼容性的容器,其中包括vector 以及map

    出于快速理解的目的,开头显示的代码较短。有兴趣的可以点击上面的demo链接。

    【讨论】:

    • "根据变量的数量,它可能因类而异。" 而且这种差异仍然必须存在,因为每个类都必须有一行调用此Attach 函数。所以这不是样板的一部分;唯一实际的样板是operator&lt;&lt; 定义、函数的大括号和实际的ostream&lt;&lt; 位。 “此外,如果没有使用保护正确处理,所有这些函数都会在生产中作为死代码。” 编译器通常不会发出没有被调用的函数。
    • "最好在它们的构造函数中,您可以 Attach() 要打印的成员变量。" 由于您的 Attach 函数现在是静态的,以及std::function 本身,从类的构造函数调用 Attach 只会导致一堆用相同的函数覆盖函数。
    • @NicolBolas,关于“样板”部分,我重视一致性和可读性。当打印从库代码发生时,可以确保均匀打印。此外,只有一个Attach() 比通过friend 函数更直观。继承ostream 提高了可读性,因为它声明这个类具有打印能力。关于覆盖的第二条评论,我认为您已经监督了代码中的延迟初始化。 Attach() 中的代码将有效地为每个类(而不是每个类对象)运行一次。
    • 我删除它是因为我搞砸了编辑:( 在多级继承中,它需要虚拟继承这对性能很不利。不要为你所做的事情买单'不需要。我们必须先重载它这是一个相当危险的想法。现在如果我们想打印成员名称怎么办?如果我们想打印一个类的索引而不打印另一个类怎么办? 如果我们想用十六进制打印一些成员怎么办?这变得比你写friend operator&lt;&lt; 还要快。
    • virtual 继承不需要。已编辑。 @n.'代词'm。以 1 方式打印的成员已经在上面的链接中进行了演示。在最新版本中,我还添加了对 std::vector 之类的容器的支持(还不是 map,但应该很容易)。对于自定义打印,可以随时定义自己的自定义operator&lt;&lt; 或修改上述库。另一方面,担心virtual 的事情(继承或方法调用)通常是过早的优化。尤其是在这种 i/o 操作繁重的情况下。
    猜你喜欢
    • 2012-03-17
    • 1970-01-01
    • 2013-10-10
    • 2013-11-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-02-10
    相关资源
    最近更新 更多