Rozważmy przypadek, w którym musisz napisać kod, aby wykonać jakieś dyskretne zadanie, jak czytanie danych wejściowych od użytkownika, wysyłanie czegoś do pliku lub obliczanie określonej wartości. Implementując ten kod, zasadniczo masz dwie możliwości:
- Napisz kod jako część istniejącej funkcji (tzw. pisanie kodu „in-place” lub „inline”).
- Utwórz nową funkcję (i ewentualnie podfunkcje) do obsługi zadania.
Umieszczenie kodu w nowej funkcji zapewnia wiele potencjalnych korzyści, ponieważ małe funkcje:
- Są łatwiejsze do odczytania i zrozumienia w kontekście całego programu.
- Łatwiejsze do ponownego użycia, ponieważ funkcje są z natury modułowe.
- Łatwiejsze do aktualizacji, ponieważ kod wymaga modyfikacji tylko w jednym miejscu.
Jednak jedną wadą używania nowej funkcji jest to, że za każdym razem, gdy funkcja jest wywoływana, występuje pewien wzrost wydajności. Rozważmy następujący przykład:
#include <iostream>
int min(int x, int y)
{
return (x < y) ? x : y;
}
int main()
{
std::cout << min(5, 6) << '\n';
std::cout << min(3, 2) << '\n';
return 0;
}Kiedy napotkane zostanie wywołanie min() , CPU musi zapisać adres aktualnie wykonywanej instrukcji (aby wiedzieć, dokąd wrócić później) wraz z wartościami różnych rejestrów CPU (aby można było je odtworzyć po powrocie). Następnie należy utworzyć instancję parametrów x i y i następnie je zainicjować. Następnie ścieżka wykonania musi przeskoczyć do kodu w funkcji min() . Kiedy funkcja się kończy, program musi wrócić do miejsca wywołania funkcji, a wartość zwracana musi zostać skopiowana, aby można było ją wyprowadzić. Należy to zrobić dla każdego wywołania funkcji.
Cała dodatkowa praca, która musi zostać wykonana podczas konfigurowania, ułatwiania i/lub czyszczenia po wykonaniu jakiegoś zadania (w tym przypadku wywołania funkcji), nazywana jest narzutem.
W przypadku funkcji, które są duże i/lub wykonują złożone zadania, obciążenie wywołania funkcji jest zazwyczaj nieistotne w porównaniu z ilością czasu potrzebnego na wykonanie funkcji. Jednakże w przypadku małych funkcji (takich jak min() powyżej) koszty ogólne mogą być większe niż czas potrzebny do faktycznego wykonania kodu funkcji! W przypadkach, gdy mała funkcja jest często wywoływana, użycie funkcji może skutkować znacznym spadkiem wydajności w porównaniu z zapisaniem tego samego kodu w miejscu.
Rozwijanie inline
Na szczęście kompilator C++ ma sztuczkę, której może użyć, aby uniknąć takich kosztów ogólnych: Rozwijanie inline to proces, w którym wywołanie funkcji jest zastępowane kodem z definicji wywoływanej funkcji.
Na przykład, jeśli kompilator rozwinął się min() w powyższym przykładzie wynikowy kod będzie wyglądał następująco:
#include <iostream>
int main()
{
std::cout << ((5 < 6) ? 5 : 6) << '\n';
std::cout << ((3 < 2) ? 3 : 2) << '\n';
return 0;
}Zauważ, że dwa wywołania funkcji min() zostały zastąpione kodem w treści funkcji min() (z wartością argumentów zastąpioną parametrami). Pozwala nam to uniknąć narzutu związanego z tymi wywołaniami, zachowując jednocześnie wyniki kodu.
Wydajność kodu wbudowanego
Oprócz usunięcia kosztów wywołania funkcji, rozwinięcie wbudowane może również pozwolić kompilatorowi na bardziej efektywną optymalizację wynikowego kodu — na przykład ponieważ wyrażenie ((5 < 6) ? 5 : 6) jest teraz wyrażeniem stałym, kompilator może jeszcze bardziej zoptymalizować pierwszą instrukcję w main() Do std::cout << 5 << '\n';.
Jednak rozwinięcie wbudowane ma swój potencjalny koszt: jeśli treść rozwijanej funkcji wymaga więcej instrukcji niż zastępowane wywołanie funkcji, wówczas każde rozwinięcie wbudowane spowoduje powiększenie pliku wykonywalnego. Większe pliki wykonywalne są zwykle wolniejsze (z powodu niedopasowania do pamięci podręcznej).
Decyzja o tym, czy funkcja będzie korzystna, jeśli zostanie wbudowana w funkcję inline (ponieważ usunięcie narzutu wywołania funkcji przewyższa koszt większego pliku wykonywalnego) nie jest prosta. Ekspansja inline może skutkować poprawą wydajności, spadkiem wydajności lub brakiem zmian w wydajności, w zależności od względnego kosztu wywołania funkcji, rozmiaru funkcji i innych optymalizacji, jakie można przeprowadzić.
Rozwijanie inline najlepiej sprawdza się w przypadku prostych, krótkich funkcji (np. nie więcej niż kilka instrukcji), szczególnie w przypadkach, gdy pojedyncze wywołanie funkcji może zostać wykonane więcej niż raz (np. wywołania funkcji w pętli).
W przypadku rozwijania inline występuje
Każda funkcja należy do jednej z dwóch kategorii, gdzie wywołania funkcji:
- Można rozszerzyć (większość funkcji należy do tej kategorii).
- Nie można rozwinąć.
Większość funkcji należy do kategorii „może”: ich wywołania funkcji można rozszerzyć, jeśli i kiedy jest to korzystne. W przypadku funkcji w tej kategorii nowoczesny kompilator oceni każdą funkcję i każde wywołanie funkcji, aby ustalić, czy to konkretne wywołanie funkcji skorzysta na rozwinięciu wbudowanym. Kompilator może zdecydować się na żadne, niektóre lub wszystkie wywołania funkcji do danej funkcji.
Wskazówka
Nowoczesne kompilatory optymalizujące podejmują decyzję o tym, kiedy funkcje powinny zostać rozwinięte w linii.
Najczęstszym rodzajem funkcji, którego nie można rozwinąć w linii, jest funkcja, której definicja znajduje się w innej jednostce tłumaczenia. Ponieważ kompilator nie widzi definicji takiej funkcji, nie wie, czym zastąpić wywołanie funkcji!
Słowo kluczowe inline, historycznie
Historycznie rzecz biorąc, kompilatory albo nie miały możliwości określenia, czy rozszerzanie wbudowane byłoby korzystne, albo nie były w tym zbyt dobre. Z tego powodu w języku C++ podano słowo kluczowe inline, które pierwotnie miało być użyte jako wskazówka dla kompilatora, że (prawdopodobnie) dana funkcja mogłaby odnieść korzyść, gdyby została rozwinięta w linii.
Funkcja zadeklarowana przy użyciu inline nazywana jest funkcją wstawianą.
Oto przykład użycia inline słowem kluczowym:
#include <iostream>
inline int min(int x, int y) // inline keyword means this function is an inline function
{
return (x < y) ? x : y;
}
int main()
{
std::cout << min(5, 6) << '\n';
std::cout << min(3, 2) << '\n';
return 0;
}Jednak we współczesnym C++ słowo kluczowe inline nie jest już używane do żądania tego funkcję można rozwinąć w linii. Jest ku temu kilka powodów:
- Użycie
inlineżądanie rozwinięcia wbudowanego jest formą przedwczesnej optymalizacji, a niewłaściwe użycie może w rzeczywistości zaszkodzić wydajności. - Klasa
inlinesłowo kluczowe to tylko wskazówka pomagająca kompilatorowi określić, gdzie przeprowadzić rozwinięcie wbudowane. Kompilator może całkowicie zignorować żądanie i może to zrobić. Kompilator może także swobodnie wykonywać wbudowane rozszerzanie funkcji, które nie używająinlinesłowa kluczowego w ramach normalnego zestawu optymalizacji. - Klasa
inlinesłowo kluczowe zostało zdefiniowane na niewłaściwym poziomie szczegółowości. Używamy słowa kluczowegoinlinew definicji funkcji, ale rozwinięcie wbudowane jest w rzeczywistości określane dla wywołania funkcji. Rozszerzanie niektórych wywołań funkcji może być korzystne, a szkodliwe rozszerzanie innych, i nie ma na to żadnej składni, która mogłaby na to wpłynąć.
Nowoczesne kompilatory optymalizujące są zazwyczaj dobre w określaniu, które wywołania funkcji powinny być wykonywane w trybie inline - w większości przypadków lepiej niż ludzie. W rezultacie kompilator prawdopodobnie zignoruje lub zdewaluuje każde użycie inline w celu zażądania wbudowanego rozwinięcia funkcji.
Najlepsza praktyka
Nie używaj słowa kluczowego inline w celu zażądania wbudowanego rozwinięcia funkcji.
Inline słowo kluczowe, nowocześnie
W poprzednich rozdziałach wspominaliśmy, że nie należy implementować funkcji (z zewnętrznym połączeniem) w plikach nagłówkowych, ponieważ gdy te nagłówki zostaną zawarte w wielu plikach .cpp, definicja funkcji zostanie skopiowana do wielu plików .cpp. Pliki te zostaną następnie skompilowane, a linker zgłosi błąd, ponieważ zauważy, że zdefiniowałeś tę samą funkcję więcej niż raz, co stanowi naruszenie reguły jednej definicji.
W nowoczesnym C++ termin inline ewoluował i oznacza „dozwolonych jest wiele definicji”. Zatem funkcja inline to taka, którą można definiować w wielu jednostkach tłumaczeniowych (bez naruszania ODR).
Funkcje inline mają dwa podstawowe wymagania:
- Kompilator musi być w stanie zobaczyć pełną definicję funkcji inline w każdej jednostce tłumaczenia, w której funkcja jest używana (sama deklaracja forward nie wystarczy). Na jednostkę tłumaczeniową może wystąpić tylko jedna taka definicja, w przeciwnym razie wystąpi błąd kompilacji.
- Definicja może wystąpić po momencie użycia, jeśli podana zostanie również deklaracja forward. Jednak kompilator prawdopodobnie nie będzie w stanie przeprowadzić rozwinięcia wbudowanego, dopóki nie zobaczy definicji (więc wszelkie zastosowania pomiędzy deklaracją a definicją prawdopodobnie nie będą kandydatami do rozwinięcia wbudowanego).
- Każda definicja funkcji wbudowanej (z zewnętrznym połączeniem, które funkcje mają domyślnie) musi być identyczna, w przeciwnym razie spowoduje niezdefiniowane zachowanie.
Reguła
Kompilator musi być w stanie zobaczyć pełną definicję funkcji wbudowanej, gdziekolwiek jest ona używana, oraz wszystkie definicje funkcji wbudowanej (z powiązaniem zewnętrznym) musi być identyczne (w przeciwnym razie spowoduje to niezdefiniowane zachowanie).
Powiązana treść
Powiązania wewnętrzne omówimy na lekcji 7.6 – Powiązania wewnętrzne i powiązania zewnętrzne na lekcji 7.7 -- Powiązania zewnętrzne i deklaracje zmiennych forward.
Linker skonsoliduje wszystkie definicje funkcji wbudowanych dla identyfikatora w jedną definicję (w ten sposób nadal spełniając wymagania reguły jednej definicji).
Oto przykład:
main.cpp:
#include <iostream>
double circumference(double radius); // forward declaration
inline double pi() { return 3.14159; }
int main()
{
std::cout << pi() << '\n';
std::cout << circumference(2.0) << '\n';
return 0;
}math.cpp
inline double pi() { return 3.14159; }
double circumference(double radius)
{
return 2.0 * pi() * radius;
}Zauważ, że oba pliki mają definicja funkcji pi() -- jednakże, ponieważ ta funkcja została oznaczona jako inline, jest to dopuszczalne, a linker usunie ich duplikaty. Jeśli usuniesz inline słowo kluczowe z obu definicji pi(), otrzymasz naruszenie ODR (ponieważ duplikaty definicji funkcji innych niż wbudowane są niedozwolone).
Odczyt opcjonalny
Chociaż historyczne użycie inline (w celu wykonania rozszerzenia inline) i współczesne użycie inline (w celu umożliwienia wielu definicji) może wydawać się nieco niezwiązane, są one wysoce ze sobą powiązane.
Historycznie załóżmy, że mieliśmy jakąś trywialną funkcję, która jest doskonałym kandydatem do rozszerzenia wbudowanego, więc oznaczamy ją jako inline. Aby faktycznie wykonać wbudowane rozwinięcie wywołania funkcji, kompilator musi być w stanie zobaczyć pełną definicję tej funkcji w każdej jednostce translacyjnej, w której funkcja jest używana — w przeciwnym razie nie wiedziałby, czym zastąpić każde wywołanie funkcji. Funkcja zdefiniowana w innej jednostce tłumaczeniowej nie może być rozwinięta w linii w aktualnie kompilowanej jednostce tłumaczeniowej.
Często zdarza się, że trywialne funkcje wbudowane są potrzebne w wielu jednostkach tłumaczących. Jednak gdy tylko skopiujemy definicję funkcji do każdej jednostki translacyjnej (zgodnie z wcześniejszym wymaganiem), kończy się to naruszeniem wymagania ODR, zgodnie z którym funkcja ma tylko jedną definicję na program. Najlepszym rozwiązaniem tego problemu było po prostu zwolnienie funkcji inline z wymogu ODR, zgodnie z którym w każdym programie istnieje tylko jedna definicja.
Więc historycznie używaliśmy inline do żądania rozszerzenia inline, a zwolnienie ODR było szczegółem wymaganym, aby takie funkcje mogły być rozszerzane inline na wiele jednostek tłumaczeniowych. Nowocześnie używamy inline dla zwolnienia ODR i pozwól kompilatorowi zająć się wbudowanymi rozszerzeniami. Mechanika działania funkcji inline się nie zmieniła, skupiamy się na tym.
Możesz się zastanawiać, dlaczego funkcje inline mogły być wyłączone z ODR, ale funkcje nieinline nadal muszą przestrzegać tej części ODR. W przypadku funkcji innych niż wbudowane oczekujemy, że funkcja zostanie zdefiniowana dokładnie raz (w pojedynczej jednostce translacyjnej). Jeśli linker przebiega przez wiele definicji funkcji innej niż wbudowana, zakłada, że jest to spowodowane konfliktem nazewnictwa pomiędzy dwiema niezależnie zdefiniowanymi funkcjami. Każde wywołanie funkcji innej niż wbudowana z więcej niż jedną definicją prowadziłoby do potencjalnej niejasności co do tego, która definicja jest właściwa do wywołania. Jednak w przypadku funkcji wbudowanych zakłada się, że wszystkie definicje dotyczą tej samej funkcji wbudowanej, zatem wywołania funkcji w obrębie tej jednostki tłumaczenia można rozszerzyć w trybie inline. A jeśli wywołanie funkcji nie jest rozwinięte w linii, nie ma dwuznaczności co do tego, która z wielu definicji jest właściwa do dopasowania wywołania — każda z nich jest w porządku!
Funkcje wbudowane są zwykle definiowane w plikach nagłówkowych, gdzie można je #included na górze dowolnego pliku z kodem, który musi widzieć pełną definicję identyfikatora. Zapewnia to, że wszystkie definicje wbudowane identyfikatora są identyczne.
pi.h:
#ifndef PI_H
#define PI_H
inline double pi() { return 3.14159; }
#endifmain.cpp:
#include "pi.h" // will include a copy of pi() here
#include <iostream>
double circumference(double radius); // forward declaration
int main()
{
std::cout << pi() << '\n';
std::cout << circumference(2.0) << '\n';
return 0;
}math.cpp
#include "pi.h" // will include a copy of pi() here
double circumference(double radius)
{
return 2.0 * pi() * radius;
}Jest to szczególnie przydatne w przypadku bibliotekach zawierających tylko nagłówek, które stanowią jeden lub więcej plików nagłówkowych implementujących pewne możliwości (nie są uwzględniane żadne pliki .cpp). Biblioteki zawierające tylko nagłówki są popularne, ponieważ nie trzeba dodawać plików źródłowych do projektu, aby z nich korzystać, ani niczego, co wymaga łączenia. Po prostu #dołączasz bibliotekę zawierającą tylko nagłówek i możesz jej używać.
Powiązana treść
Pokazujemy praktyczny przykład, w którym w lekcji użyto metody inline do generowania liczb losowych biblioteki zawierającej tylko nagłówek. 8.15 -- Globalne liczby losowe (Random.h).
Dla zaawansowanych czytelników
Następujące funkcje są domyślnie wbudowane:
- Funkcje zdefiniowane wewnątrz definicji klasy, struktury lub typu unii (14.3 -- Element funkcje).
- Constexpr / funkcje consteval (F.1 -- Funkcje Constexpr).
- Funkcje tworzone domyślnie z szablonów funkcji (11.7 -- Szablon funkcji instancja).
W większości przypadków nie powinieneś oznaczać swoich funkcji lub zmiennych jako wbudowanych, chyba że definiujesz je w pliku nagłówkowym (i nie są one już domyślnie wbudowane).
Najlepsza praktyka
Unikaj używania słowa kluczowego inline chyba że masz ku temu konkretny, ważny powód (np. definiujesz te funkcje lub zmienne w pliku nagłówkowym).
Dlaczego nie umieścić wszystkich funkcji w wierszu i zdefiniowanych w pliku nagłówkowym?
Głównie dlatego, że może to znacznie wydłużyć czas kompilacji.
Gdy nagłówek zawierający funkcję wbudowaną zostanie #uwzględniony w pliku źródłowym, ta definicja funkcji zostanie skompilowana jako część tego tłumaczenia unit. Funkcja wbudowana #wbudowana w 6 jednostek tłumaczeniowych będzie miała swoją definicję skompilowaną 6 razy (zanim linker zdeduplikuje definicje). I odwrotnie, definicja funkcji zdefiniowanej w pliku źródłowym zostanie skompilowana tylko raz, niezależnie od tego, w ilu jednostkach tłumaczeniowych zawarta jest jej deklaracja forward.
Po drugie, jeśli funkcja zdefiniowana w pliku źródłowym ulegnie zmianie, tylko ten pojedynczy plik źródłowy będzie musiał zostać przekompilowany w przypadku funkcji wbudowanej w nagłówku zmiany pliku, każdy plik kodu zawierający ten nagłówek (bezpośrednio lub poprzez inny nagłówek) wymaga ponownej kompilacji. W przypadku dużych projektów może to spowodować kaskadę rekompilacji i mieć drastyczne skutki.
Zmienne wbudowane C++17
W powyższym przykładzie pi() zostały zapisane jako funkcja zwracająca stałą wartość. Byłoby bardziej proste, gdyby zamiast tego pi zostały zaimplementowane jako zmienna (stała). przed C++17 występowały pewne przeszkody i nieefektywności.
C++17 wprowadza zmienne wbudowane, które są zmiennymi, które można definiować w wielu plikach. Zmienne wbudowane działają podobnie do funkcji wbudowanych i mają te same wymagania (kompilator musi widzieć identyczną pełną definicję wszędzie tam, gdzie używana jest zmienna).
Dla zaawansowanych czytelników
Następujące zmienne są domyślnie wbudowane:
- Statyczne elementy danych constexpr 15.6 -- Statyczne zmienne składowe.
W przeciwieństwie do funkcji constexpr, zmienne constexpr nie są domyślnie wbudowane (z wyjątkiem tych wymienionych powyżej)!
Zilustrujemy to częste zastosowanie zmiennych wbudowanych na lekcji 7.10 — Współdzielenie stałych globalnych w wielu plikach (przy użyciu zmiennych wbudowanych).

