Andriy Berestovskyy
Andriy BerestovskyyC/C++ Software Engineer @ Nexthink

C++ w praktyce: Zwracanie wielu wartości

Poznaj najlepszy sposób na zwrócenie wielu wartości z funkcji C++17.
19.03.20216 min
C++ w praktyce: Zwracanie wielu wartości

Quiz na: Czy ja to już wiem?

Jaki jest najlepszy sposób na zwrócenie wielu wartości z funkcji C++17?

1. Używanie parametrów wyjściowych:

auto output_1(int &i1) { i1 = 11; return 12; }


2. Używanie lokalnej struktury:

auto struct_2() { struct _ { int i1, i2; }; return _{21, 22}; }


3. Używanie std::pair:

auto pair_2() { return std::make_pair(31, 32); }


4. Używanie std::tuple:

auto tuple_2() { return std::make_tuple(41, 42); }


Odpowiedź znajdziesz na samym dole artykułu.

Przykład praktyczny: dlaczego warto zwracać wiele wartości?

Typowym przykładem jest std::from_chars(), funkcja C++17 podobna do strtol(). from_chars() zwraca 3 wartości: przeanalizowany numer, kod błędu i wskaźnik do pierwszego niepoprawnego znaku.

Ta funkcja używa kombinacji technik: liczba jest zwracana jako parametr wyjściowy, ale kod błędu i wskaźnik są zwracane jako struktura. Dlaczego tak się dzieje? Przeanalizujmy…

Analiza

Zwrócenie wielu wartości przy użyciu parametrów wyjściowych

Przykładowy kod:

auto output_1(int &i1) {
  i1 = 11;   // Output first parameter
  return 12; // Return second value
}
// Use volatile pointers so compiler could not inline the function
auto (*volatile output_1_ptr)(int &i1) = output_1;
int main() {
  int o1, o2;            // Define local variables
  o2 = output_1_ptr(o1); // Output 1st param and assign the 2nd
  printf("output_1 o1 = %d, o2 = %d\n", o1, o2);
}


Kod kompiluje się do:

output_1(int&):
  mov [rdi], 11       # Output first param to the address in rdi
  mov eax, 12         # Return second value in eax
  ret
main:                 # Note: simplified
  lea rdi, [rsp + 4]  # Load address of the 1st param (on stack)
  call [output_1_ptr] # Call output_1 using a pointer
  mov esi, [rsp + 4]  # Load 1st param from the stack
  mov ecx, eax        # Load 2nd param from eax
  call printf


Explorer kompilatora:
https://godbolt.org/z/Fan8OH

Plusy:

  • Klasyka. Łatwe do zrozumienia.
  • Działa z dowolnym standardem C++, w tym z C (za pomocą wskaźników).
  • Obsługuje przeciążanie funkcji.


Wady:

  • Adres pierwszego parametru musi zostać wczytany przed wywołaniem funkcji.
  • Pierwszy parametr jest przekazywany za pomocą stosu, co jest powolne.
  • Ze względu na System V AMD64 ABI, możemy przekazywać rejestry do max 6 adresów. Potrzeba kontenera, by zwrócić więcej niż 6 parametrów, co jest jeszcze bardziej powolne.


Aby zilustrować ostatnie minusy, oto przykładowy kod, który zwraca  7 parametrów:

// Output more than 6 params
int output_7(int &i1, int &i2, int &i3, int &i4,
             int &i5, int &i6, int &i7) {
  i1 = 11;
  i2 = 12;
  i3 = 13;
  i4 = 14;
  i5 = 15;
  i6 = 16;
  i7 = 17;
  return 18;
}


Kod z output_7() kompiluje się do:

output_7(int&, int&, int&, int&, int&, int&, int&):
  mov [rdi], 11      #
  mov [rsi], 12      # Addresses of the first 6 params get passed
  mov [rdx], 13      # via rdi, rsi, rdx, rcx, r8, and r9
  mov [rcx], 14      # according to System V AMD64 ABI
  mov [r8], 15       # (for Linux, macOS, FreeBSD etc)
  mov [r9], 16       #
  mov rax, [rsp + 8] # But address for the 7th is on the stack,
  mov [rax], 17      # which is slow
  mov eax, 18
  ret


Siódmy adres jest przekazywany przez stos, więc umieszczamy adres na stosie, a następnie czytamy go ze stosu, by następnie zapisać wartość pod tym adresem… Trochę za dużo operacji pamięci. To powolne :(

Zwracanie wielu wartości przy użyciu lokalnej struktury

Przykładowy kod:

auto struct_2() {
  struct _ {        // Declare a local structure with 2 integers
    int i1, i2;
  };
  return _{21, 22}; // Return the local structure
}
// Use volatile pointers so compiler could not inline the function
auto (*volatile struct_2_ptr)() = struct_2;
int main() {
  auto [s1, s2] = struct_2_ptr(); // Structured binding declaration
  printf("struct_2 s1 = %d, s2 = %d\n", s1, s2);
}


Kod kompiluje się do:

struct_2():
  movabs rax, 0x1600000015  # Just return 2 integers in rax
  ret
main:                 # Note: simplified
  call [struct_2_ptr] # No need to load output param addresses
  mov rdx, rax        # Just use the values returned in rax
  shr rdx, 32         # High 32 bits of rax
  mov rcx, rax
  mov esi, ecx        # Low 32 bits of rax
  call printf


Explorer kompilatora:
https://godbolt.org/z/Q7P4q0

Plusy:

  • Działa z dowolnym standardem C++, w tym z C, chociaż struktura musi być zadeklarowana poza zakresem funkcji.
  • Zwraca do 128 bitów w rejestrach, bez użycia stosu. To całkiem szybkie!
  • Nie wymaga adresów parametrów, co pozwala kompilatorowi lepiej optymalizować kod.


Minusy:

  • Wymaga deklaracji structured binding C++17.
  • Funkcja nie może być przeciążona, ponieważ zwracany typ nie jest częścią identyfikacji funkcji.


Co się stanie, gdy spróbujemy zwrócić więcej wartości? Zgodnie z System V AMD64 ABI, wartości do 128 bitów są przechowywane w RAX i RDX. Tak więc do czterech 32-bitowych liczb całkowitych może zostać zwróconych w rejestrach. Wystarczy jeden bajt więcej, byśmy byli zmuszeni użyć stosu.

Mimo to nie musimy ładować adresów parametrów wyjściowych, więc ten sposób jest szybszy niż metoda parametrów wyjściowych.

Zwracanie wielu wartości za pomocą std::pair:

Przykład:

auto pair_2() { return std::make_pair(31, 32); } // Just one line!
// Use volatile pointers so compiler could not inline the function
auto (*volatile pair_2_ptr)() = pair_2;
int main() {
  auto [p1, p2] = pair_2_ptr();  // Structured binding declaration
  printf("pair_2 p1 = %d, p2 = %d\n", p1, p2);
}


Wygenerowany kod assembly:

pair_2():
  movabs rax, 0x200000001f  # Just return 2 integers in rax
  ret
main:                 # Note: simplified
  call [pair_2_ptr]   # Just call the function
  mov rdx, rax        # Use the values returned in rax
  shr rdx, 32
  mov rcx, rax
  mov esi, ecx
  call printf


Explorer kompilatora:
https://godbolt.org/z/9iXzSb

Plusy:

  • Tylko jeden wiersz kodu!
  • Nie ma potrzeby deklarowania lokalnej struktury.
  • Podobnie jak w przypadku struktur, zwraca do 128 bitów w rejestrach, bez użycia stosu.


Wady:

  • Para to tylko dwie zwrócone wartości.
  • Podobnie jak w przypadku struktur, funkcja nie może być przeciążona.

Zwracanie wielu wartości przy użyciu std::tuple

Przykład:

auto tuple_2() { return std::make_tuple(41, 42); } // Just one line!
// Use volatile pointers so compiler could not inline the function
auto (*volatile tuple_2_ptr)() = tuple_2;
int main() {
  auto [t1, t2] = tuple_2_ptr();  // Structured binding declaration
  printf("tuple_2 t1 = %d, t2 = %d\n", t1, t2);
}


Kod kompiluje się do:

tuple_2():
  movabs rax, 0x290000002a. # Good start, but...
  mov [rdi], rax            # Indirect write to a output parameter?
  mov rax, rdi              # Return the address of the parameter
  ret
main:                 # Note: simplified
  mov rdi, rsp        # Pass stack pointer as a parameter
  call [tuple_2_ptr]  # Call the function
  mov edx, [rsp]      # Get the values from the stack
  mov esi, [rsp + 4]
  call printf


Explorer kompilatora:
https://godbolt.org/z/hSVV72

Plusy:

  • Kod źródłowy jest jednowierszowy, tak jak w przypadku std::pair.
  • W przeciwieństwie do std::pair, łatwo tu dodać więcej wartości.

Wady:

  • Niestety, wygenerowany kod assemblera budzi mieszane uczucia. Musimy przekazać adres krotki wyjściowej do funkcji, po jednej na krotkę.
  • Nawet dla dwóch liczb całkowitych (64 bity) wartości zwracane są zawsze na stosie. To znowu całkiem powolne...


A co, jeśli zwrócimy więcej wartości w krotce? Dodanie większej liczby wartości nie zmieni wiele w wykonywanych instrukcjach: nadal przekazujemy tylko jeden adres wskazujący na stos, a następnie umieszczamy wartości pod tym adresem (na stosie), by następnie zdjąć je z powrotem ze stosu, aby użyć printf().

Jest to podejście wolniejsze niż użycie pary czy struktury, które zwracają do 128 bitów w rejestrach, ale jest szybsze niż parametry wyjściowe, gdzie musimy przekazać do funkcji kilka adresów, a nie tylko jeden.

Wnioski

  1. Najszybsze metody na zwrócenie wielu parametrów w C++17, to użycie lokalnej struktury  oraz std::pair.
  2. Metoda std::pair powinna być używana do zwracania dwóch wartości, jako najszybsza i najwygodniejsza metoda.  
  3. Użyj parametrów wyjściowych, gdy potrzebne jest przeciążenie funkcji. Dlatego właśnie metoda std::from_chars() używa parametrów wyjściowych i zwraca stukturę.


Pełny kod źródłowy:
https://github.com/berestovskyy/applied-cpp

Odpowiedź na pytanie z quizu: Czy ja to już wiem?

Metoda std::pair jest najbardziej wygodnym i szybkim sposobem, aby zwrócić dwie wartości. Jeśli musimy zwrócić więcej niż dwie wartości, musimy użyć metody struktury lokalnej (szybszej) lub metody std::tuple (wygodniejszej).


Oryginalny tekst w języku angielskim możesz przeczytać tutaj.
<p>Loading...</p>