C ++ Tuple vs Struct


96

Apakah ada perbedaan antara menggunakan a std::tupledan data-only struct?

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

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

Dari apa yang saya temukan secara online, saya menemukan bahwa ada dua perbedaan utama: the structlebih mudah dibaca, sedangkan yang tuplememiliki banyak fungsi umum yang dapat digunakan. Haruskah ada perbedaan kinerja yang signifikan? Juga, apakah tata letak data kompatibel satu sama lain (dicor secara bergantian)?


Saya baru saja berkomentar bahwa saya lupa tentang pertanyaan cor : implementasi implementasi tupleadalah ditentukan, oleh karena itu tergantung pada implementasi Anda. Secara pribadi, saya tidak akan mengandalkannya.
Matthieu M.

Jawaban:


32

Kami memiliki diskusi serupa tentang tuple dan struct dan saya menulis beberapa tolok ukur sederhana dengan bantuan dari salah satu rekan saya untuk mengidentifikasi perbedaan dalam hal kinerja antara tuple dan struct. Kami pertama kali mulai dengan struct default dan 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>;

Kami kemudian menggunakan Celero untuk membandingkan kinerja struct dan tuple sederhana kami. Di bawah ini adalah kode tolok ukur dan hasil kinerja yang dikumpulkan menggunakan gcc-4.9.2 dan 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);
}

Hasil kinerja dikumpulkan dengan 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.

Dan hasil performa dikumpulkan dengan menggunakan 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.

Dari hasil di atas kita dapat melihat dengan jelas

  • Tuple lebih cepat dari struct default

  • Produk biner dengan dentang memiliki kinerja yang lebih tinggi daripada gcc. clang-vs-gcc bukanlah tujuan dari diskusi ini jadi saya tidak akan mendalami secara detail.

Kita semua tahu bahwa menulis operator == atau <atau> untuk setiap definisi struct akan menjadi tugas yang menyakitkan dan bermasalah. Mari ganti komparator khusus kita menggunakan std :: tie dan jalankan kembali benchmark kita.

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.

Sekarang kita dapat melihat bahwa menggunakan std :: tie membuat kode kita lebih elegan dan lebih sulit untuk membuat kesalahan, namun, kita akan kehilangan sekitar 1% kinerja. Saya akan tetap menggunakan solusi std :: tie untuk saat ini karena saya juga menerima peringatan tentang membandingkan angka floating point dengan komparator yang disesuaikan.

Sampai saat ini kami belum memiliki solusi untuk membuat kode struct kami berjalan lebih cepat. Mari kita lihat fungsi swap dan tulis ulang untuk melihat apakah kita dapat memperoleh kinerja apa pun:

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

Hasil kinerja dikumpulkan menggunakan 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.

Dan hasil performansi dikumpulkan dengan menggunakan 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.

Sekarang struct kita sedikit lebih cepat dari pada tuple sekarang (sekitar 3% dengan clang dan kurang dari 1% dengan gcc), namun, kita perlu menulis fungsi swap yang disesuaikan untuk semua struct kita.


24

Jika Anda menggunakan beberapa tupel yang berbeda dalam kode Anda, Anda dapat memadatkan jumlah fungsi yang Anda gunakan. Saya mengatakan ini karena saya sering menggunakan bentuk fungsi berikut:

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

Ini mungkin tampak seperti berlebihan tetapi untuk setiap tempat dalam struct saya harus membuat objek functor baru menggunakan struct tetapi untuk tupel, saya hanya mengubah N. Lebih baik dari itu, saya dapat melakukan ini untuk setiap tupel tunggal sebagai lawan untuk membuat functor baru untuk setiap struct dan untuk setiap variabel anggota. Jika saya memiliki N struct dengan variabel anggota M yang berfungsi NxM saya perlu membuat (skenario kasus yang lebih buruk) yang dapat diringkas menjadi satu bit kode.

Secara alami, jika Anda akan menggunakan cara Tuple, Anda juga perlu membuat Enum untuk bekerja dengan mereka:

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

dan boom, kode Anda benar-benar dapat dibaca:

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

karena itu menggambarkan dirinya sendiri ketika Anda ingin mendapatkan item yang terkandung di dalamnya.


8
Uh ... C ++ memiliki penunjuk fungsi, jadi template <typename C, typename T, T C::*> struct struct_less { template <typename C> bool operator()(C const&, C const&) const; };harus memungkinkan. Mengeja itu sedikit kurang nyaman, tetapi hanya ditulis sekali.
Matthieu M.

17

Tuple telah dibangun secara default (untuk == dan! = Membandingkan setiap elemen, untuk <. <= ... membandingkan pertama, jika sama membandingkan kedua ...) pembanding: http://en.cppreference.com/w/ cpp / utility / tuple / operator_cmp

edit: seperti yang tercantum dalam komentar C ++ 20 operator pesawat luar angkasa memberi Anda cara untuk menentukan fungsionalitas ini dengan satu baris kode (jelek, tapi masih hanya satu).


1
Di C ++ 20, ini diperbaiki dengan boilerplate minimal menggunakan operator pesawat luar angkasa .
John McFarlane

6

Nah, inilah patokan yang tidak membangun sekelompok tupel di dalam operator struct == (). Ternyata ada dampak kinerja yang cukup signifikan dari penggunaan tuple, seperti yang diharapkan mengingat tidak ada dampak kinerja sama sekali dari penggunaan POD. (Penyelesai alamat menemukan nilai dalam pipa instruksi bahkan sebelum unit logika melihatnya.)

Hasil umum dari menjalankan ini di komputer saya dengan VS2015CE menggunakan pengaturan 'Rilis' default:

Structs took 0.0814905 seconds.
Tuples took 0.282463 seconds.

Silakan monyet dengan itu sampai Anda puas.

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

Terima kasih untuk ini. Saya memperhatikan bahwa ketika dioptimalkan dengan -O3, tuplesmengambil waktu kurang dari structs.
Simog

3

Nah, struct POD seringkali dapat (ab) digunakan dalam pembacaan dan pembuatan serial bersebelahan tingkat rendah. Tuple mungkin lebih dioptimalkan dalam situasi tertentu dan mendukung lebih banyak fungsi, seperti yang Anda katakan.

Gunakan apa pun yang lebih sesuai untuk situasi tersebut, tidak ada preferensi umum. Saya pikir (tetapi saya belum membandingkannya) bahwa perbedaan kinerja tidak akan signifikan. Tata letak data kemungkinan besar tidak kompatibel dan spesifik penerapannya.


3

Sejauh "fungsi generik" berjalan, Boost.Fusion layak mendapatkan cinta ... dan terutama BOOST_FUSION_ADAPT_STRUCT .

Merobek dari halaman: 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))

Ini berarti bahwa semua algoritma Fusion sekarang dapat diterapkan pada struct demo::employee.


EDIT : Mengenai perbedaan kinerja atau kompatibilitas tata letak, tupletata letak adalah implementasi yang didefinisikan sehingga tidak kompatibel (dan dengan demikian Anda tidak boleh menggunakan salah satu representasi) dan secara umum saya mengharapkan tidak ada perbedaan kinerja-bijaksana (setidaknya dalam Rilis) berkat sebaris dari get<N>.


16
Saya tidak percaya bahwa ini adalah jawaban yang dipilih terbanyak. Ia bahkan tidak menjawab pertanyaan itu. Pertanyaannya adalah tentang tuples dan structs, bukan boost!
gsamaras

@ G.Samaras: Pertanyaannya adalah tentang perbedaan antara tupel dan struct, dan terutama banyaknya algoritme untuk memanipulasi tupel dengan tidak adanya algoritme untuk memanipulasi struct (dimulai dengan mengulang bidangnya). Jawaban ini menunjukkan bahwa celah ini dapat dijembatani dengan menggunakan Boost.Fusion, membawa structalgoritma sebanyak yang ada pada tuple. Saya menambahkan uraian kecil tentang dua pertanyaan yang diajukan.
Matthieu M.

3

Juga, apakah tata letak data kompatibel satu sama lain (dicor secara bergantian)?

Anehnya, saya tidak bisa melihat tanggapan langsung untuk bagian pertanyaan ini.

Jawabannya adalah: tidak . Atau setidaknya tidak dapat diandalkan, karena tata letak tupel tidak ditentukan.

Pertama, struct Anda adalah Tipe Tata Letak Standar . Urutan, padding, dan penyelarasan anggota ditentukan dengan baik oleh kombinasi standar dan platform ABI Anda.

Jika tupel adalah tipe tata letak standar, dan kami tahu bidang-bidangnya disusun dalam urutan jenis yang ditentukan, kami mungkin memiliki keyakinan bahwa itu akan cocok dengan struct.

Tuple biasanya diimplementasikan menggunakan pewarisan, dengan salah satu dari dua cara: gaya rekursif Loki / Modern C ++ Design lama, atau gaya variadic yang lebih baru. Bukan jenis Tata Letak Standar, karena keduanya melanggar ketentuan berikut:

  1. (sebelum C ++ 14)

    • tidak memiliki kelas dasar dengan anggota data non-statis, atau

    • tidak memiliki anggota data non-statis di kelas yang paling banyak diturunkan dan paling banyak satu kelas dasar dengan anggota data non-statis

  2. (untuk C ++ 14 dan yang lebih baru)

    • Memiliki semua anggota data non-statis dan bidang bit yang dideklarasikan di kelas yang sama (baik semua dalam turunan atau semua di beberapa basis)

karena setiap kelas dasar daun berisi satu elemen tupel (NB. tupel elemen tunggal mungkin adalah tipe tata letak standar, meskipun tidak terlalu berguna). Jadi, kita tahu standar tidak menjamin tuple memiliki padding atau alignment yang sama dengan struct.

Selain itu, perlu dicatat bahwa tupel gaya rekursif yang lebih lama umumnya akan meletakkan anggota data dalam urutan terbalik.

Secara anekdot, ini terkadang berhasil dalam praktik untuk beberapa kompiler dan kombinasi jenis bidang di masa lalu (dalam satu kasus, menggunakan tupel rekursif, setelah membalik urutan bidang). Ini pasti tidak berfungsi dengan andal (di seluruh kompiler, versi, dll.) Sekarang, dan tidak pernah dijamin sejak awal.


1

Seharusnya tidak ada perbedaan kinerja (bahkan yang tidak signifikan). Setidaknya dalam kasus normal, mereka akan menghasilkan tata letak memori yang sama. Meskipun demikian, casting di antara mereka mungkin tidak diharuskan untuk bekerja (meskipun saya kira ada kemungkinan yang cukup adil biasanya akan berhasil).


4
Sebenarnya menurut saya mungkin ada perbedaan kecil. A structharus mengalokasikan setidaknya 1 byte untuk setiap subobjek sementara saya berpikir bahwa a tupledapat lolos dengan mengoptimalkan objek kosong. Juga, berkaitan dengan pengepakan dan penyelarasan, bisa jadi tupel memiliki lebih banyak kelonggaran.
Matthieu M.

1

Pengalaman saya adalah bahwa dari waktu ke waktu fungsionalitas mulai merayap pada jenis (seperti struct POD) yang dulunya adalah pemegang data murni. Hal-hal seperti modifikasi tertentu yang seharusnya tidak memerlukan pengetahuan orang dalam tentang data, mempertahankan invarian, dll.

Itu hal yang bagus; itu dasar dari orientasi objek. Itulah alasan mengapa C dengan kelas ditemukan. Menggunakan kumpulan data murni seperti tuple tidak terbuka untuk ekstensi logis seperti itu; struct adalah. Itulah mengapa saya hampir selalu memilih struct.

Terkait adalah bahwa seperti semua "objek data terbuka", tupel melanggar paradigma penyembunyian informasi. Anda tidak dapat mengubahnya nanti tanpa membuang grosir tupel. Dengan struct, Anda dapat bergerak secara bertahap menuju fungsi akses.

Masalah lainnya adalah keamanan tipe dan kode yang mendokumentasikan sendiri. Jika fungsi Anda menerima objek bertipe inbound_telegramatau location_3Dsudah jelas; jika menerima unsigned char *atau tuple<double, double, double>tidak: telegram bisa keluar, dan tupel bisa menjadi terjemahan alih-alih lokasi, atau mungkin pembacaan suhu minimum dari akhir pekan yang panjang. Ya, Anda dapat mengetikkan untuk memperjelas niat, tetapi itu tidak benar-benar mencegah Anda melewati suhu.

Masalah ini cenderung menjadi penting dalam proyek yang melebihi ukuran tertentu; kelemahan dari tuple dan keuntungan dari kelas yang rumit menjadi tidak terlihat dan memang menjadi beban dalam proyek kecil. Memulai dengan kelas yang tepat bahkan untuk kumpulan data kecil yang tidak mencolok membayar dividen terlambat.

Tentu saja satu strategi yang layak adalah menggunakan pemegang data murni sebagai penyedia data yang mendasari untuk pembungkus kelas yang menyediakan operasi pada data tersebut.


1

Jangan khawatir tentang kecepatan atau tata letak, itu pengoptimalan nano, dan bergantung pada kompilernya, dan tidak pernah ada cukup perbedaan untuk memengaruhi keputusan Anda.

Anda menggunakan struct untuk hal-hal yang secara bermakna dimiliki bersama untuk membentuk keseluruhan.

Anda menggunakan tupel untuk hal-hal yang bersama-sama secara kebetulan. Anda dapat menggunakan tupel secara spontan dalam kode Anda.


1

Dilihat dari jawaban lain, pertimbangan kinerja paling minimal.

Jadi itu benar-benar harus turun ke kepraktisan, keterbacaan dan pemeliharaan. Dan structumumnya lebih baik karena menciptakan jenis yang lebih mudah dibaca dan dipahami.

Terkadang, sebuah std::tuple(atau bahkan std::pair) mungkin diperlukan untuk menangani kode dengan cara yang sangat umum. Misalnya, beberapa operasi yang terkait dengan paket parameter variadic tidak mungkin dilakukan tanpa sesuatu seperti std::tuple. std::tieadalah contoh yang bagus tentang kapan std::tupledapat meningkatkan kode (sebelum C ++ 20).

Tetapi di mana pun Anda dapat menggunakan a struct, Anda mungkin harus menggunakan file struct. Ini akan memberikan makna semantik pada elemen tipe Anda. Itu sangat berharga dalam memahami dan menggunakan tipe. Pada gilirannya, ini dapat membantu menghindari kesalahan konyol:

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

0

Saya tahu ini adalah tema lama, namun saya sekarang akan membuat keputusan tentang bagian dari proyek saya: haruskah saya menggunakan cara tuple atau struct. Setelah membaca utas ini saya punya beberapa ide.

  1. Tentang wheaties dan tes kinerja: harap dicatat bahwa Anda biasanya dapat menggunakan memcpy, memset dan trik serupa untuk struct. Ini akan membuat kinerja JAUH lebih baik daripada tupel.

  2. Saya melihat beberapa keuntungan dalam tupel:

    • Anda dapat menggunakan tupel untuk mengembalikan kumpulan variabel dari fungsi atau metode dan mengurangi sejumlah tipe yang Anda gunakan.
    • Berdasarkan fakta bahwa tuple memiliki operator <, ==,> yang telah ditentukan sebelumnya, Anda juga dapat menggunakan tuple sebagai kunci dalam map atau hash_map yang jauh lebih hemat biaya daripada struct di mana Anda perlu mengimplementasikan operator ini.

Saya telah mencari web dan akhirnya mencapai halaman ini: https://arne-mertz.de/2017/03/smelly-pair-tuple/

Umumnya saya setuju dengan kesimpulan akhir dari atas.


1
Ini terdengar lebih seperti apa yang Anda kerjakan dan bukan jawaban untuk pertanyaan spesifik itu, atau?
Dieter Meemken

Tidak ada yang menghalangi Anda untuk menggunakan memcpy dengan tupel.
Peter - Pulihkan Monica
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.