c++元组与结构体

C++ Tuple vs Struct

本文关键字:结构体 元组 c++      更新时间:2023-10-16

使用std::tuple和只使用数据的struct有什么区别吗?

typedef std::tuple<int, double, bool> foo_t;
struct bar_t {
    int id;
    double value;
    bool dirty;
}

从我在网上找到的东西,我发现有两个主要的区别:struct更具可读性,而tuple有许多可以使用的通用函数。是否会有显著的性能差异?此外,数据布局是否相互兼容(可互换转换)?

我们对元组和结构体进行了类似的讨论,我在一位同事的帮助下编写了一些简单的基准测试,以确定元组和结构体在性能方面的差异。我们首先从一个默认结构体和一个元组开始。

struct StructData {
    int X;
    int Y;
    double Cost;
    std::string Label;
    bool operator==(const StructData &rhs) {
        return std::tie(X,Y,Cost, Label) == std::tie(rhs.X, rhs.Y, rhs.Cost, rhs.Label);
    }
    bool operator<(const StructData &rhs) {
        return X < rhs.X || (X == rhs.X && (Y < rhs.Y || (Y == rhs.Y && (Cost < rhs.Cost || (Cost == rhs.Cost && Label < rhs.Label)))));
    }
};
using TupleData = std::tuple<int, int, double, std::string>;
然后使用Celero比较简单结构体和元组的性能。下面是使用gcc-4.9.2和clang-4.0.0收集的基准代码和性能结果:
std::vector<StructData> test_struct_data(const size_t N) {
    std::vector<StructData> data(N);
    std::transform(data.begin(), data.end(), data.begin(), [N](auto item) {
        std::random_device rd;
        std::mt19937 gen(rd());
        std::uniform_int_distribution<> dis(0, N);
        item.X = dis(gen);
        item.Y = dis(gen);
        item.Cost = item.X * item.Y;
        item.Label = std::to_string(item.Cost);
        return item;
    });
    return data;
}
std::vector<TupleData> test_tuple_data(const std::vector<StructData> &input) {
    std::vector<TupleData> data(input.size());
    std::transform(input.cbegin(), input.cend(), data.begin(),
                   [](auto item) { return std::tie(item.X, item.Y, item.Cost, item.Label); });
    return data;
}
constexpr int NumberOfSamples = 10;
constexpr int NumberOfIterations = 5;
constexpr size_t N = 1000000;
auto const sdata = test_struct_data(N);
auto const tdata = test_tuple_data(sdata);
CELERO_MAIN
BASELINE(Sort, struct, NumberOfSamples, NumberOfIterations) {
    std::vector<StructData> data(sdata.begin(), sdata.end());
    std::sort(data.begin(), data.end());
    // print(data);
}
BENCHMARK(Sort, tuple, NumberOfSamples, NumberOfIterations) {
    std::vector<TupleData> data(tdata.begin(), tdata.end());
    std::sort(data.begin(), data.end());
    // print(data);
}

clang-4.0.0收集的性能结果

Celero
Timer resolution: 0.001000 us
-----------------------------------------------------------------------------------------------------------------------------------------------
     Group      |   Experiment    |   Prob. Space   |     Samples     |   Iterations    |    Baseline     |  us/Iteration   | Iterations/sec  | 
-----------------------------------------------------------------------------------------------------------------------------------------------
Sort            | struct          | Null            |              10 |               5 |         1.00000 |    196663.40000 |            5.08 | 
Sort            | tuple           | Null            |              10 |               5 |         0.92471 |    181857.20000 |            5.50 | 
Complete.

和使用gcc-4.9.2收集的性能结果

Celero
Timer resolution: 0.001000 us
-----------------------------------------------------------------------------------------------------------------------------------------------
     Group      |   Experiment    |   Prob. Space   |     Samples     |   Iterations    |    Baseline     |  us/Iteration   | Iterations/sec  | 
-----------------------------------------------------------------------------------------------------------------------------------------------
Sort            | struct          | Null            |              10 |               5 |         1.00000 |    219096.00000 |            4.56 | 
Sort            | tuple           | Null            |              10 |               5 |         0.91463 |    200391.80000 |            4.99 | 
Complete.

从以上结果我们可以清楚地看到

  • 元组比默认结构体

  • 更快
  • clang生成的二进制比gcc生成的二进制性能更高。clang-vs-gcc不是本次讨论的目的,所以我不会深入讨论细节。

我们都知道写a ==或<对于每个结构体定义的Or>操作符将是一项痛苦且充满bug的任务。让我们使用std::tie替换自定义比较器并重新运行基准测试。

bool operator<(const StructData &rhs) {
    return std::tie(X,Y,Cost, Label) < std::tie(rhs.X, rhs.Y, rhs.Cost, rhs.Label);
}
Celero
Timer resolution: 0.001000 us
-----------------------------------------------------------------------------------------------------------------------------------------------
     Group      |   Experiment    |   Prob. Space   |     Samples     |   Iterations    |    Baseline     |  us/Iteration   | Iterations/sec  | 
-----------------------------------------------------------------------------------------------------------------------------------------------
Sort            | struct          | Null            |              10 |               5 |         1.00000 |    200508.20000 |            4.99 | 
Sort            | tuple           | Null            |              10 |               5 |         0.90033 |    180523.80000 |            5.54 | 
Complete.
现在我们可以看到,使用std::tie使我们的代码更优雅,更不易出错,然而,我们将损失大约1%的性能。现在我将继续使用std::tie解决方案,因为我还收到了关于将浮点数与自定义比较器进行比较的警告。

到目前为止,我们还没有任何解决方案可以使结构体代码运行得更快。让我们来看看swap函数并重写它,看看我们是否可以获得任何性能:

struct StructData {
    int X;
    int Y;
    double Cost;
    std::string Label;
    bool operator==(const StructData &rhs) {
        return std::tie(X,Y,Cost, Label) == std::tie(rhs.X, rhs.Y, rhs.Cost, rhs.Label);
    }
    void swap(StructData & other)
    {
        std::swap(X, other.X);
        std::swap(Y, other.Y);
        std::swap(Cost, other.Cost);
        std::swap(Label, other.Label);
    }  
    bool operator<(const StructData &rhs) {
        return std::tie(X,Y,Cost, Label) < std::tie(rhs.X, rhs.Y, rhs.Cost, rhs.Label);
    }
};

使用clang-4.0.0收集的性能结果

Celero
Timer resolution: 0.001000 us
-----------------------------------------------------------------------------------------------------------------------------------------------
     Group      |   Experiment    |   Prob. Space   |     Samples     |   Iterations    |    Baseline     |  us/Iteration   | Iterations/sec  | 
-----------------------------------------------------------------------------------------------------------------------------------------------
Sort            | struct          | Null            |              10 |               5 |         1.00000 |    176308.80000 |            5.67 | 
Sort            | tuple           | Null            |              10 |               5 |         1.02699 |    181067.60000 |            5.52 | 
Complete.

和使用gcc-4.9.2收集的性能结果

Celero
Timer resolution: 0.001000 us
-----------------------------------------------------------------------------------------------------------------------------------------------
     Group      |   Experiment    |   Prob. Space   |     Samples     |   Iterations    |    Baseline     |  us/Iteration   | Iterations/sec  | 
-----------------------------------------------------------------------------------------------------------------------------------------------
Sort            | struct          | Null            |              10 |               5 |         1.00000 |    198844.80000 |            5.03 | 
Sort            | tuple           | Null            |              10 |               5 |         1.00601 |    200039.80000 |            5.00 | 
Complete.

现在我们的结构体比元组稍微快一点(在clang中大约是3%,在gcc中不到1%),但是,我们确实需要为我们所有的结构体编写自定义的交换函数。

如果您在代码中使用了几个不同的元组,那么您可以减少所使用的函子的数量。我这样说是因为我经常使用以下形式的函子:

template<int N>
struct tuple_less{
    template<typename Tuple>
    bool operator()(const Tuple& aLeft, const Tuple& aRight) const{
        typedef typename boost::tuples::element<N, Tuple>::type value_type;
        BOOST_CONCEPT_REQUIRES((boost::LessThanComparable<value_type>));
        return boost::tuples::get<N>(aLeft) < boost::tuples::get<N>(aRight);
    }
};

这可能看起来有点过分,但是对于结构体中的每个位置,我都必须使用结构体创建一个全新的functor对象,但对于元组,我只需更改N。比这更好的是,我可以对每个元组都这样做,而不是为每个结构体和每个成员变量创建一个全新的函函数。如果我有N个结构体,有M个成员变量,我需要创建(最坏的情况),可以压缩成一小段代码。

当然,如果你打算用元组的方式,你还需要创建enum来处理它们:

typedef boost::tuples::tuple<double,double,double> JackPot;
enum JackPotIndex{
    MAX_POT,
    CURRENT_POT,
    MIN_POT
};

和boom,你的代码完全可读:

double guessWhatThisIs = boost::tuples::get<CURRENT_POT>(someJackPotTuple);

,因为当您想要获取其中包含的项时,它会描述自己。

Tuple默认内置(for == and !=它比较每个元素,for <.<=…比较第一个,如果相同比较第二个…)比较器:http://en.cppreference.com/w/cpp/utility/tuple/operator_cmp

edit:如注释中所述,c++ 20太空船操作符提供了一种用一行(丑陋但仍然只有一行)代码指定此功能的方法。

这里有一个基准测试,它没有在结构符operator==()中构造一堆元组。事实证明,使用tuple对性能有相当大的影响,正如人们所期望的那样,使用pod对性能没有任何影响。(地址解析器甚至在逻辑单元看到该值之前就在指令管道中找到它。)

在我的机器上使用默认的'Release'设置运行此程序的常见结果:

Structs took 0.0814905 seconds.
Tuples took 0.282463 seconds.

请摆弄它,直到你满意为止。

#include <iostream>
#include <string>
#include <tuple>
#include <vector>
#include <random>
#include <chrono>
#include <algorithm>
class Timer {
public:
  Timer() { reset(); }
  void reset() { start = now(); }
  double getElapsedSeconds() {
    std::chrono::duration<double> seconds = now() - start;
    return seconds.count();
  }
private:
  static std::chrono::time_point<std::chrono::high_resolution_clock> now() {
    return std::chrono::high_resolution_clock::now();
  }
  std::chrono::time_point<std::chrono::high_resolution_clock> start;
};
struct ST {
  int X;
  int Y;
  double Cost;
  std::string Label;
  bool operator==(const ST &rhs) {
    return
      (X == rhs.X) &&
      (Y == rhs.Y) &&
      (Cost == rhs.Cost) &&
      (Label == rhs.Label);
  }
  bool operator<(const ST &rhs) {
    if(X > rhs.X) { return false; }
    if(Y > rhs.Y) { return false; }
    if(Cost > rhs.Cost) { return false; }
    if(Label >= rhs.Label) { return false; }
    return true;
  }
};
using TP = std::tuple<int, int, double, std::string>;
std::pair<std::vector<ST>, std::vector<TP>> generate() {
  std::mt19937 mt(std::random_device{}());
  std::uniform_int_distribution<int> dist;
  constexpr size_t SZ = 1000000;
  std::pair<std::vector<ST>, std::vector<TP>> p;
  auto& s = p.first;
  auto& d = p.second;
  s.reserve(SZ);
  d.reserve(SZ);
  for(size_t i = 0; i < SZ; i++) {
    s.emplace_back();
    auto& sb = s.back();
    sb.X = dist(mt);
    sb.Y = dist(mt);
    sb.Cost = sb.X * sb.Y;
    sb.Label = std::to_string(sb.Cost);
    d.emplace_back(std::tie(sb.X, sb.Y, sb.Cost, sb.Label));
  }
  return p;
}
int main() {
  Timer timer;
  auto p = generate();
  auto& structs = p.first;
  auto& tuples = p.second;
  timer.reset();
  std::sort(structs.begin(), structs.end());
  double stSecs = timer.getElapsedSeconds();
  timer.reset();
  std::sort(tuples.begin(), tuples.end());
  double tpSecs = timer.getElapsedSeconds();
  std::cout << "Structs took " << stSecs << " seconds.nTuples took " << tpSecs << " seconds.n";
  std::cin.get();
}

另外,数据布局是否相互兼容(可互换转换)?

奇怪的是,我找不到对这部分问题的直接回应。

答案是:。或者至少不可靠,因为元组的布局是未指定的。

首先,你的结构是一个标准布局类型。成员的排序、填充和对齐是由标准和平台ABI组合定义的。

如果元组是标准布局类型,并且我们知道字段是按照指定类型的顺序排列的,那么我们可以有信心它与结构匹配。

元组通常使用继承实现,有两种方式之一:旧的Loki/Modern c++ Design递归风格,或较新的可变风格。它们都不是标准布局类型,因为它们都违反以下条件:

  1. (c++ 14之前)

    • 没有具有非静态数据成员的基类,或者

    • 在大多数派生类中没有非静态数据成员,并且最多有一个具有非静态数据成员的基类

  2. (c++ 14及以上版本)

    • 将所有非静态数据成员和位字段声明在同一个类中(要么全部在派生类中,要么全部在基类中)

,因为每个叶基类都包含一个元组元素。单元素元组可能是一种标准布局类型,尽管不是很有用)。因此,我们知道标准不保证元组具有与结构体相同的填充或对齐方式。

此外,值得注意的是,旧的递归式元组通常以相反的顺序排列数据成员。

有趣的是,在过去的一些编译器和字段类型的组合中,它有时在实践中起作用(在一种情况下,在颠倒字段顺序后使用递归元组)。它现在肯定不能可靠地工作(跨编译器,版本等),而且从一开始就没有得到保证。
从其他答案来看,性能方面的考虑最多是最少的。

所以它真的应该归结为实用性,可读性和可维护性。struct通常更好,因为它创建的类型更容易阅读和理解。

有时,std::tuple(甚至std::pair)可能需要以高度泛型的方式处理代码。例如,如果没有std::tuple之类的东西,一些与可变参数包相关的操作是不可能的。std::tie是一个很好的例子,说明std::tuple可以改进代码(在c++ 20之前)。

但是当你可以使用struct时,你可能应该使用struct。它将赋予你的类型元素语义意义。这对于理解和使用这种类型是非常宝贵的。反过来,这可以帮助避免愚蠢的错误:

// hard to get wrong; easy to understand
cat.arms = 0;
cat.legs = 4;
// easy to get wrong; hard to understand
std::get<0>(cat) = 0;
std::get<1>(cat) = 4;

嗯,POD结构体通常可以(ab)用于低级连续块读取和序列化。正如你所说,元组在某些情况下可能会更优化,并支持更多的功能。

根据情况选择合适的方式,没有一般的偏好。我认为(但我没有对其进行基准测试)性能差异不会很大。数据布局很可能不兼容且特定于实现。

不要担心速度或布局,这是纳米级优化,取决于编译器,从来没有足够的差异来影响你的决定。

你使用结构体来表示有意义地属于一起形成一个整体的东西。

使用元组表示碰巧在一起的东西。您可以在代码中自发地使用元组。

就"泛型函数"而言,Boost。核聚变应该得到一些爱…尤其是BOOST_FUSION_ADAPT_STRUCT

从页面撕下:ABRACADBRA

namespace demo
{
    struct employee
    {
        std::string name;
        int age;
    };
}
// demo::employee is now a Fusion sequence
BOOST_FUSION_ADAPT_STRUCT(
    demo::employee
    (std::string, name)
    (int, age))

这意味着所有的Fusion算法现在都适用于结构体demo::employee


EDIT:关于性能差异或布局兼容性,tuple的布局是实现定义的,因此不兼容(因此您不应该在两种表示之间进行强制转换),一般来说,由于get<N>的内联,我希望在性能方面没有差异(至少在发布中)。

我的经验是,随着时间的推移,功能开始在类型(如POD结构)上逐渐增加,这些类型曾经是纯粹的数据持有者。比如某些不需要数据内部知识的修改,维护不变量等。

这是一件好事;它是面向对象的基础。这就是为什么带类的C语言被发明的原因。使用像元组这样的纯数据集合不允许这样的逻辑扩展;结构体。这就是为什么我几乎总是选择结构体。

相关的是,像所有"开放数据对象"一样,元组违反了信息隐藏范式。你不能在之后更改它而不抛出整个元组。使用结构体,您可以逐步转向访问函数。

另一个问题是类型安全和自文档代码。如果你的函数接收到一个inbound_telegramlocation_3D类型的对象,这很明显;如果它收到unsigned char *tuple<double, double, double>,则不是:电报可能是出站的,并且元组可能是翻译而不是位置,或者可能是长周末的最低温度读数。是的,您可以通过类型定义来明确意图,但这实际上并不能阻止您传递温度。

这些问题往往在超过一定规模的项目中变得重要;元组的缺点和精细类的优点变得不可见,并且在小型项目中确实是一种开销。从适当的类开始,即使是不起眼的小数据聚合也会带来后期的好处。

当然,一个可行的策略是使用纯数据持有人作为类包装器的底层数据提供者,该类包装器提供对该数据的操作。

不应该有性能差异(即使是微不足道的差异)。至少在正常情况下,它们将导致相同的内存布局。尽管如此,他们之间的选角可能不需要工作(尽管我猜有一个相当公平的机会,通常会)。

我知道这是一个古老的主题,但是我现在要对我的项目的一部分做出决定:我应该走元路还是结构路。看完这篇文章,我有了一些想法。

  1. 关于wheaties和性能测试:请注意,您通常可以使用memcpy, memset和类似的结构体技巧。这将使性能优于元组。

  2. 我看到了元组的一些优势:

    • 您可以使用元组从函数或方法返回变量的集合,并减少您使用的类型的数量。
    • 基于tuple具有预定义的<,==,>操作符的事实,您还可以使用tuple作为map或hash_map中的键,这比您需要实现这些操作符的结构体更具成本效益。

我搜索了网页,最终找到了这个页面:https://arne-mertz.de/2017/03/smelly-pair-tuple/

总的来说,我同意上面的最后结论。

没有兼容C内存布局等负担,更有利于优化。