有没有更好的方法来重载 ostream 运算符<<?

Are there better ways to overload ostream operator<<?

提问人: 提问时间:12/22/2019 最后编辑:iammilind 更新时间:1/6/2020 访问量:1605

问:

假设您有以下代码:

#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<<

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

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

C++ 运算符重载 C++17 IOSTREAM ostream

评论

0赞 Lightness Races in Orbit 12/22/2019
你认为有更好的选择吗?想想它在做什么,并询问您不需要哪些部分。有吗?
1赞 Lightness Races in Orbit 12/22/2019
那会有什么不同呢?它会是什么样子?试一试,给它计时,看看它是否更快。
3赞 Davis Herring 12/22/2019
您在这里有什么性能问题?外部 I/O 的成本通常比代码的成本高得多,如果不是这样,iostreams 库无论如何都不会特别快。
4赞 Nicol Bolas 12/26/2019
@EmanueleOggiano:“埃马努埃莱·奥贾诺(Emanuele Oggiano)正在寻找一个规范的答案。当不清楚问题到底是什么时,很难提供“规范答案”。“过载运算符的方法<<是什么意思?哪些事情我们可以改变,哪些事情我们不能改变?您对现有代码有哪些性能问题,这些问题的基础是什么?
1赞 Peter 12/30/2019
这取决于你如何定义“更好”——没有它(根据定义),就不可能有规范的答案。不要求 an 是 ,只要类为成员提供可访问的 getter 即可。如果这些 getter 在代码中被内联(并且实现实际上内联了这些 getter,这实际上也不是必需的 - 因为内联是对编译器的提示,而不是指令),那么可测量的差异将很少operator<<()friend

答:

-1赞 TonyK 12/26/2019 #1

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

-3赞 iammilind 12/26/2019 #2

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

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

我建议从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;

用法

为那些需要设施的类继承上述类。将通过 base 自动包含在这些类的定义中。所以没有额外的工作。例如:operator<<friendostream

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

演示

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

要求

  • 这里的目的是提高打印变量的可读性和均匀性
  • 无论继承如何,每个可打印类都应该有其单独的ostream<T>
  • 对象应该已定义或继承,以便能够编译operator<<ostream<T>

设施

现在,这是一个很好的库组件。以下是我到目前为止添加的附加设施。

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

开头显示的代码较短,以便快速理解。有兴趣的人可以点击上面的演示链接。

评论

0赞 Nicol Bolas 12/26/2019
"根据变量的数量,它可能因类而异。这种差异仍然必须存在,因为每个类都必须有一行调用此函数。所以这不是样板的一部分;唯一实际的样板是定义、函数的大括号和实际位。“此外,如果不能正确使用防护装置处理,所有这些功能都会在生产中成为死代码。”编译器通常不会发出未被调用的函数。Attachoperator<<ostream<<
0赞 Nicol Bolas 12/26/2019
"最好在它们的构造函数中,您可以 Attach() 要打印的成员变量。由于您的函数现在是静态的,并且本身也是静态的,因此从类的构造函数调用只会导致一堆使用相同函数覆盖函数。Attachstd::functionAttach
0赞 iammilind 12/26/2019
@NicolBolas,关于“样板”部分,我重视统一性和可读性。当从库代码进行打印时,可以确保统一打印。此外,只有一个比通过函数要直观得多。继承可提高可读性,因为它声明此类具有打印功能。关于你关于覆盖的第二条评论,我认为你已经监督了代码中的延迟初始化。里面的代码将有效地只为每个类运行一次(而不是每个类对象)。Attach()friendostreamAttach()
1赞 n. m. could be an AI 12/26/2019
我删除它,因为我搞砸了编辑:(在多级继承中,需要虚拟继承这对性能不利。不要为你不需要的东西买单。我们必须首先超载这是一个相当危险的想法。现在,如果我们要打印成员姓名怎么办?如果我们想为一个类打印索引,而不为另一个类打印索引,该怎么办?如果我们想用十六进制打印一些成员怎么办?这变得凌乱的速度比你写的要快。friend operator<<
0赞 iammilind 12/26/2019
virtual不需要继承。编辑。@n.'代词'm. 成员以 1 种方式打印已经在上面的链接中演示了。在最新版本中,我还添加了对类似容器的支持(还没有,但应该很容易)。对于自定义打印,可以随时定义其自定义或修改上述库。顺便说一句,担心事情(继承或方法调用)通常是过早的优化。特别是在这种情况下,I/O 操作很繁重。std::vectormapoperator<<virtual
2赞 sweenish 12/31/2019 #3

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

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

现在存在的重载将在尝试执行某些输出格式化操作时“中断”。<<

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

这不会使整个输出对齐。相反,它只左对齐成员。这是因为您一次将一个项目放入流中。 将抓取下一个项目以左对齐,这只是类输出的一部分。Examplefirst_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

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

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

评论

1赞 sweenish 12/31/2019
如果你能投反对票,你可以告诉我为什么。我不反对学习。
0赞 sancho.s ReinstateMonicaCellio 1/1/2020 #4

据我了解,这个问题提出了两个歧义点:

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

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

我设想了至少 3 种方法来使 :ostream operator<<

  1. -way,正如你发布的那样。friend
  2. 非正向,具有返回类型。friendauto
  3. 非正向,具有返回类型。friendstd::ostream

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

  1. 在优化模式(with )下编译/链接,并分别循环10000次后,所有3种方法都提供基本相同的性能。-O3std::cout

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

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


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

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


定义重载运算符的三种方法的代码<<
(我删除了默认构造函数,因为它们在这里无关紧要)。

方法 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_;
};

方法2:

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

方法3:

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赞 walnut 1/1/2020
这不是一个合适的基准。1. 需要循环迭代基准测试,避免测量缓存效应。2.我不知道如何获得任何有用的精确打印微秒计数。启用优化后,每次执行应该只需要大约 1us 到 3us 左右的时间。3. 如果你纠正了这些事情,那么仍然有表面上的区别,但这仅仅是因为一些测试用例需要打印更长的字符串。如果给所有变量都具有相同的值,那么就不再有明显的区别了。example*_
0赞 walnut 1/1/2020
请参见 godbolt.org/z/0TYZGe。具有原始值但循环并具有纳秒输出的基准位于顶部,而在所有测试中具有相同值的基准位于底部。中间和右边分别是 GCC 和 Clang 生成的代码。
0赞 sancho.s ReinstateMonicaCellio 1/1/2020
@walnut - 你是对的!考虑到评论,我修改了代码和答案。