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
- Najszybsze metody na zwrócenie wielu parametrów w C++17, to użycie lokalnej struktury oraz
std::pair
. - Metoda
std::pair
powinna być używana do zwracania dwóch wartości, jako najszybsza i najwygodniejsza metoda. - 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.