C ++ Tupla vs Struct

98

Existe alguma diferença entre usar a std::tuplee somente dados struct?

typedef std::tuple<int, double, bool> foo_t;

struct bar_t {
    int id;
    double value;
    bool dirty;
}

Pelo que descobri online, descobri que existem duas diferenças principais: o structé mais legível, enquanto o tupletem muitas funções genéricas que podem ser usadas. Deve haver alguma diferença significativa de desempenho? Além disso, o layout de dados é compatível entre si (fundidos de forma intercambiável)?

Alex Koay
fonte
Acabei de comentar que havia esquecido a questão do elenco : a implementação da tupleimplementação está definida, portanto, depende da sua implementação. Pessoalmente, não contaria com isso.
Matthieu M.

Respostas:

33

Temos uma discussão semelhante sobre tupla e estrutura e escrevo alguns benchmarks simples com a ajuda de um de meus colegas para identificar as diferenças em termos de desempenho entre tupla e estrutura. Começamos primeiro com uma estrutura padrão e uma tupla.

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

Em seguida, usamos o Celero para comparar o desempenho de nossa estrutura simples e tupla. Abaixo está o código de referência e os resultados de desempenho coletados usando gcc-4.9.2 e 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);
}

Resultados de desempenho coletados com 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.

E resultados de desempenho coletados usando 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.

A partir dos resultados acima, podemos ver claramente que

  • Tupla é mais rápida do que uma estrutura padrão

  • O produto binário por clang tem desempenho superior ao do gcc. clang-vs-gcc não é o propósito desta discussão, portanto, não vou entrar em detalhes.

Todos nós sabemos que escrever um operador == ou <ou> para cada definição de struct será uma tarefa dolorosa e cheia de bugs. Vamos substituir nosso comparador personalizado usando std :: tie e executar novamente nosso benchmark.

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.

Agora podemos ver que usar std :: tie torna nosso código mais elegante e é mais difícil cometer erros, no entanto, perderemos cerca de 1% de desempenho. Vou ficar com a solução std :: tie por enquanto, pois também recebo um aviso sobre a comparação de números de ponto flutuante com o comparador personalizado.

Até agora não temos nenhuma solução para fazer nosso código de estrutura rodar mais rápido. Vamos dar uma olhada na função de troca e reescrevê-la para ver se podemos obter algum desempenho:

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

Resultados de desempenho coletados usando o 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.

E os resultados de desempenho coletados usando 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.

Agora, nossa estrutura é um pouco mais rápida do que uma tupla (cerca de 3% com clang e menos de 1% com gcc), no entanto, precisamos escrever nossa função de troca personalizada para todas as nossas estruturas.

Hangptit
fonte
24

Se estiver usando várias tuplas diferentes em seu código, você poderá condensar o número de functores que está usando. Digo isso porque sempre usei as seguintes formas de functores:

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

Isso pode parecer um exagero, mas para cada lugar dentro da estrutura, eu teria que fazer um novo objeto functor usando uma estrutura, mas para uma tupla, eu apenas altero N. Melhor do que isso, posso fazer isso para cada tupla em vez de criar um novo functor para cada estrutura e para cada variável de membro. Se eu tiver N structs com M variáveis ​​de membro que NxM functors eu precisaria criar (pior cenário) que podem ser condensados ​​em um pequeno pedaço de código.

Naturalmente, se você for seguir o caminho das Tuplas, também precisará criar Enums para trabalhar com eles:

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

e pronto, seu código é totalmente legível:

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

porque ele descreve a si mesmo quando você deseja obter os itens contidos nele.

trigo
fonte
8
Uh ... C ++ tem ponteiros de funções, então template <typename C, typename T, T C::*> struct struct_less { template <typename C> bool operator()(C const&, C const&) const; };deve ser possível. Soletrar é um pouco menos conveniente, mas só é escrito uma vez.
Matthieu M.
17

A tupla tem um padrão embutido (para == e! = Compara cada elemento, para <. <= ... compara primeiro, se o mesmo compara o segundo ...) comparadores: http://en.cppreference.com/w/ cpp / utilitário / tupla / operador_cmp

editar: conforme observado no comentário, o operador de nave espacial C ++ 20 oferece uma maneira de especificar essa funcionalidade com uma (feia, mas ainda apenas uma) linha de código.

NoSenseEtAl
fonte
1
No C ++ 20, isso era corrigido com o mínimo de clichê usando o operador da nave espacial .
John McFarlane
6

Bem, aqui está um benchmark que não constrói um monte de tuplas dentro do operador de estrutura == (). Acontece que há um impacto bastante significativo no desempenho do uso da tupla, como seria de se esperar, visto que não há impacto no desempenho de todo o uso de PODs. (O resolvedor de endereço encontra o valor no pipeline de instrução antes mesmo que a unidade lógica o veja.)

Resultados comuns ao executar isso em minha máquina com VS2015CE usando as configurações padrão de 'Versão':

Structs took 0.0814905 seconds.
Tuples took 0.282463 seconds.

Por favor, brinque com ele até ficar satisfeito.

#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();
}
Khatharr
fonte
Obrigado por isso. Percebi que quando otimizado com -O3, tuplesleva menos tempo que structs.
Simog
3

Bem, uma estrutura POD pode frequentemente ser (ab) usada na leitura e serialização de blocos contíguos de baixo nível. Uma tupla pode ser mais otimizada em certas situações e suportar mais funções, como você disse.

Use o que for mais apropriado para a situação, não há preferência geral. Eu acho (mas não fiz o benchmarking) que as diferenças de desempenho não serão significativas. O layout de dados provavelmente não é compatível e não é específico da implementação.

orlp
fonte
3

Quanto à "função genérica", Boost.Fusion merece um pouco de amor ... e especialmente BOOST_FUSION_ADAPT_STRUCT .

Rasgando da página: 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))

Isso significa que todos os algoritmos Fusion agora são aplicáveis ​​à estrutura demo::employee.


EDITAR : Em relação à diferença de desempenho ou compatibilidade de layout, tupleo layout de é uma implementação definida de forma não compatível (e, portanto, você não deve lançar entre nenhuma das representações) e, em geral, eu não esperaria nenhuma diferença em termos de desempenho (pelo menos na versão), graças ao inlining de get<N>.

Matthieu M.
fonte
16
Não acredito que esta seja a resposta mais votada. Ele nem mesmo responde à pergunta. A questão é sobre tuples e structs, não sobre boost!
gsamaras
@ G.Samaras: A questão é sobre a diferença entre tuplas e struct, notavelmente, a abundância de algoritmos para manipular tuplas contra a ausência de algoritmos para manipular estruturas (começando pela iteração sobre seus campos). Essa resposta mostra que essa lacuna pode ser preenchida usando Boost.Fusion, trazendo para structs tantos algoritmos quantos houver nas tuplas. Eu adicionei uma pequena sinopse nas duas perguntas exatas feitas.
Matthieu M.
3

Além disso, o layout de dados é compatível entre si (fundidos de forma intercambiável)?

Estranhamente, não consigo ver uma resposta direta a essa parte da pergunta.

A resposta é: não . Ou pelo menos não de forma confiável, pois o layout da tupla não é especificado.

Em primeiro lugar, sua estrutura é um tipo de layout padrão . A ordem, o preenchimento e o alinhamento dos membros são bem definidos por uma combinação do padrão e da ABI de sua plataforma.

Se uma tupla fosse um tipo de layout padrão e soubéssemos que os campos foram dispostos na ordem em que os tipos são especificados, podemos ter alguma confiança de que ela corresponderia à estrutura.

A tupla é normalmente implementada usando herança, em uma das duas maneiras: o antigo estilo recursivo Loki / Modern C ++ Design ou o estilo variadic mais recente. Nenhum deles é um tipo de Layout padrão, porque ambos violam as seguintes condições:

  1. (antes de C ++ 14)

    • não tem classes básicas com membros de dados não estáticos, ou

    • não tem membros de dados não estáticos na classe mais derivada e no máximo uma classe base com membros de dados não estáticos

  2. (para C ++ 14 e posterior)

    • Tem todos os membros de dados não estáticos e campos de bits declarados na mesma classe (todos no derivado ou todos em alguma base)

visto que cada classe base folha contém um único elemento de tupla (NB. uma tupla de elemento único provavelmente é um tipo de layout padrão, embora não seja muito útil). Portanto, sabemos que o padrão não garante que a tupla tenha o mesmo preenchimento ou alinhamento da estrutura.

Além disso, é importante notar que a tupla de estilo recursivo mais antiga geralmente exibe os membros de dados na ordem reversa.

Curiosamente, às vezes funcionou na prática para alguns compiladores e combinações de tipos de campo no passado (em um caso, usando tuplas recursivas, depois de inverter a ordem dos campos). Definitivamente, não funciona de maneira confiável (entre compiladores, versões etc.) agora e nunca foi garantido em primeiro lugar.

Sem utilidade
fonte
2

Minha experiência é que, com o tempo, a funcionalidade começa a surgir em tipos (como estruturas POD) que costumavam ser portadores de dados puros. Coisas como certas modificações que não devem exigir conhecimento privilegiado dos dados, manutenção de invariantes etc.

Isso é uma coisa boa; é a base da orientação a objetos. É a razão pela qual C com classes foi inventado. O uso de coleções de dados puras como tuplas não está aberto a essa extensão lógica; structs são. É por isso que quase sempre opto por structs.

Relacionado é que, como todos os "objetos de dados abertos", as tuplas violam o paradigma de ocultação de informações. Você não pode mudar isso mais tarde, sem descartar a tupla no atacado. Com uma estrutura, você pode mover-se gradualmente para funções de acesso.

Outro problema é a segurança de tipo e o código de autodocumentação. Se sua função recebe um objeto do tipo inbound_telegramou location_3Dé claro; se recebe a unsigned char *ou tuple<double, double, double>não: o telegrama pode ser enviado e a tupla pode ser uma tradução em vez de uma localização, ou talvez as leituras de temperatura mínima do fim de semana prolongado. Sim, você pode usar o typedef para deixar as intenções claras, mas isso não impede que você ultrapasse as temperaturas.

Essas questões tendem a se tornar importantes em projetos que excedem um certo tamanho; as desvantagens das tuplas e as vantagens de classes elaboradas tornam-se invisíveis e, na verdade, são uma sobrecarga em projetos pequenos. Começar com classes adequadas, mesmo para pequenos agregados de dados imperceptíveis, paga dividendos tardios.

É claro que uma estratégia viável seria usar um portador de dados puro como o provedor de dados subjacente para um wrapper de classe que fornece operações nesses dados.

Peter - Reintegrar Monica
fonte
2

A julgar por outras respostas, as considerações de desempenho são mínimas, na melhor das hipóteses.

Portanto, realmente deve se resumir à praticidade, legibilidade e facilidade de manutenção. Estruct geralmente é melhor porque cria tipos mais fáceis de ler e entender.

Às vezes, um std::tuple(ou mesmo std::pair) pode ser necessário para lidar com o código de uma forma altamente genérica. Por exemplo, algumas operações relacionadas a pacotes de parâmetros variáveis ​​seriam impossíveis sem algo semelhante std::tuple. std::tieé um ótimo exemplo de quandostd::tuple podemos melhorar o código (antes do C ++ 20).

Mas em qualquer lugar onde você possa usar um struct, provavelmente deve usar um struct. Isso dará significado semântico aos elementos do seu tipo. Isso é inestimável para entender e usar o tipo. Por sua vez, isso pode ajudar a evitar erros bobos:

// 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;
John McFarlane
fonte
1

Não deve haver uma diferença de desempenho (mesmo que insignificante). Pelo menos no caso normal, eles resultarão no mesmo layout de memória. No entanto, a conversão entre eles provavelmente não é necessária para funcionar (embora eu ache que há uma boa chance de que normalmente funcione).

Jerry Coffin
fonte
4
Na verdade, acho que pode haver uma pequena diferença. A structdeve alocar pelo menos 1 byte para cada subobjeto, enquanto acho que tuplepode se safar com a otimização dos objetos vazios. Além disso, com relação ao empacotamento e ao alinhamento, pode ser que as tuplas tenham mais liberdade.
Matthieu M.
1

Não se preocupe com velocidade ou layout, isso é nanootimização e depende do compilador, e nunca há diferença suficiente para influenciar sua decisão.

Você usa uma estrutura para coisas que pertencem significativamente juntas para formar um todo.

Você usa uma tupla para coisas que estão juntas por coincidência. Você pode usar uma tupla espontaneamente em seu código.

gnasher729
fonte
0

Sei que é um tema antigo, mas agora estou prestes a tomar uma decisão sobre parte do meu projeto: devo seguir o método tupla ou estrutura. Depois de ler este tópico, tenho algumas idéias.

  1. Sobre os trigais e o teste de desempenho: observe que normalmente você pode usar memcpy, memset e truques semelhantes para structs. Isso tornaria o desempenho MUITO melhor do que para tuplas.

  2. Vejo algumas vantagens nas tuplas:

    • Você pode usar tuplas para retornar uma coleção de variáveis ​​de função ou método e diminuir o número de tipos que você usa.
    • Com base no fato de que a tupla tem operadores <, ==,> predefinidos, você também pode usar a tupla como uma chave no mapa ou hash_map, que é muito mais econômico do que a estrutura em que você precisa implementar esses operadores.

Pesquisei na web e finalmente cheguei a esta página: https://arne-mertz.de/2017/03/smelly-pair-tuple/

Geralmente eu concordo com uma conclusão final de cima.

Tom K
fonte
1
Parece mais com o que você está trabalhando e não com uma resposta a essa pergunta específica ou?
Dieter Meemken
Nada impede você de usar memcpy com tuplas.
Peter - Reintegrar Monica em