inline

Czirkos Zoltán · 2019.04.14.

Inline C-ben és C++-ban

Néha nagyon rövid függvényeket írunk – olyan kicsiket, hogy úgy érezzük, fölöslegesek, lassítják a programunkat. Hogy a függvényhívással töltött idő (paraméterek átadása, ugrás, visszaugrás stb.) összemérhető a függvény feladatának végrehajtási idejével. Úgy érezzük emiatt, hogy nem lesz hatékony a program. Például ebben az osztályban:

class Tort {
  private:
    int szaml, nev;
  public:
    Tort(int szaml, int nev);
    int get_szaml() const {
        return szaml;
    }
    int get_nev() const {
        return nev;
    }
};

Elrejtjük az adatot, és aztán függvényhívást kell csinálnunk ahhoz, hogy kiolvassuk azt... Sőt a függvény még csak nem is csinál semmit! Nem lesz ez kárunkra? Megéri így az adatrejtés?

Az inlining-nak nevezett optimalizációs technika miatt bőven megéri. Nem kell attól tartanunk, hogy ez lassítja a programot. Egy függvény inline-olása azt jelenti, hogy a függvény törzsét a fordító a lefordított programban beilleszti a hívás helyére, megspórolva ezzel a függvényhívás költségét.

A C/C++ programkódokba írt inline kulcsszó is ezzel kapcsolatos. De egy függvény neve elé írt inline kulcsszó nem azt jelenti, hogy az a függvény inline-olva lesz. Akkor hogy is van ez?

1. Az inlining működése

Tekintsük az alábbi kis programocskát:

#include <stdio.h>

int szorzat(int x, int y) {
    return x * y;
}

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

Vizsgáljuk meg a lefordított program Assembly kódját! Erre kiválóan alkalmas a Compiler Explorer oldal, ahol egy online felületen tehetjük meg ezt. De parancssorból is könnyen megy, gcc -S hatására fájlba írja a fordító az Assembly kódot.

A két függvényünk így fest – kivágva csak a releváns részeket:

szorzat(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, DWORD PTR [rbp-8]  ; (2)
        pop     rbp
        ret

main:
        ...
        call    scanf
        mov     edx, DWORD PTR [rbp-12]
        mov     eax, DWORD PTR [rbp-8]
        mov     esi, edx
        mov     edi, eax
        call    szorzat(int, int)       ; (1)
        ...
        call    printf
        ...

A legfontosabb két sort a számok jelölik. Az (1)-essel jelzett helyről ugrunk a főprogramból a szorzat() függvény belsejébe, itt történik meg a függvényhívás (call). Az előtte lévő néhány sor valósítja meg a paraméterátadást. A (2)-essel jelzett hely pedig maga a szorzás: imul, mint multiplication, szorozza össze a megadott számokat. Az összes többi utasítás a függvényhívás adminisztrációja; a ret, azaz return utasítás tér vissza a függvényből.

Lényegében ez az, amit el szeretnénk kerülni. Látszik, hogy mennyi többlet teendő volt ahhoz képest, hogy az érdemi munka a függvényben egyetlen egy gépi utasítást jelent, az imul-t.

Nézzük most meg ugyanezt a kódot gcc -O2 paraméterrel fordítva, tehát optimalizálva! A releváns részek:

szorzat(int, int):
        mov     eax, edi
        imul    eax, esi                    ; (2)
        ret

main:
        ...
        call    scanf
        mov     esi, DWORD PTR [rsp+12]
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        imul    esi, DWORD PTR [rsp+8]      ; (1)
        call    printf
        ...

Először is, látszik hogy a kód optimalizált. A (2)-essel jelzett helyen továbbra is ott a szorzást végző imul utasítás, de a szorzat függvény sokkal rövidebb lett, a fordító okosabban oldotta meg a paraméterátadást. A főprogramból viszont teljesen eltűnt a call szorzat hívás! Helyette a szorzó utasítás bekerült az (1)-es helyre. A függvény inline-olódott, és körülbelül nyolc utasítást megspóroltunk így.

Végezzünk el még egy kísérletet! Tegyünk egy static kulcsszót a szorzat() függvény elé. Mint tudjuk, ez azt jelenti, hogy több forrásfájlból álló program esetén más forrásfájlból nem is szeretnénk elérni ezt a függvényt. Csak ebből az egy szorzat.c fájlból lesz meghívható, a többiben nem is látszik:

static int szorzat(int x, int y) {
    return x * y;
}

Ebben az esetben a teljes lefordított program így néz ki:

.LC0:
        .string "%d %d"
.LC1:
        .string "%d"
main:
        sub     rsp, 24
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        lea     rdx, [rsp+12]
        lea     rsi, [rsp+8]
        call    scanf
        mov     esi, DWORD PTR [rsp+12]
        mov     edi, OFFSET FLAT:.LC1
        xor     eax, eax
        imul    esi, DWORD PTR [rsp+8]  ; (1)
        call    printf
        xor     eax, eax
        add     rsp, 24
        ret

Ebben az (1)-essel jelölt helyen látjuk a szorzást. A szorzat() függvény pedig teljesen eltűnt (!) a programból. Önálló függvényként már nem is létezik, csak a főprogramba beépítve látjuk azt a műveletet, ami a forráskódban még külön függvényként létezett. A fordító teljesen kihagyhatja a függvényt a lefordított programból! Ahol használtuk, oda beépítette a törzsét. Máshonnan pedig nem hívhatjuk, tehát biztos lehet benne, hogy a lefordított programba fölösleges betenni.

Vegyük észre, hogy ehhez az optimalizációhoz nem volt szükség az inline kulcsszóra! A fordító magától döntött úgy, hogy a függvénytörzset beépíti a főprogramba. Ahhoz viszont, hogy ez megtörténhessen, ismernie kellett a törzset. Látnia kellett, mi van a függvény belsejében. Ha a függvény egy másik forrásfájlban lenne definiálva, és itt csak a fejlécét ismerné, akkor erre nem lett volna lehetősége. Egyszerűen azért, mert nem tudja, mi van benne:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

int szorzat(int x, int y);

#endif

2. Az inline kulcsszó használata

Mégis, mire kell az inline kulcsszó, ha a fordító anélkül is alkalmazza a fent vázolt optimalizációt?

Ennek megértéséhez azt kell elfogadni, hogy az inline szó nem az optimalizációt engedélyezi vagy javasolja a fordító számára. Hanem a létezése a C/C++ programok fordítási modelljének egyfajta mellékterméke: ahhoz kell, hogy a függvény törzsét is a fejlécfájlba tehessük. Nem csinál mást, nem való másra, csak tárolási osztályt (linkage) ad meg a függvény számára.

Térjünk vissza az előző, szorzós függvényhez. Tegyük fel, hogy ezt a függvényt szeretnénk inline-olni. Ezért kénytelenek vagyunk a függvény törzsét is a szorzas.h fejlécfájlba tenni, mert különben a használat helyén nem fogja látni azt a fordító. Tehát a projekt fájljai így néznek ki:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

int szorzat(int x, int y) {
    return x * y;
}

#endif
szorzas.c
#include "szorzas.h"


/* szorzat() függvény
 * itt most nincs */


/* de további
 * függvények lehetnek */
main.c
#include <stdio.h>
#include "szorzas.h"

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

Mi történik, ha megpróbáljuk így lefordítani ezt a projektet? A helyzet az, hogy nem fog működni. A fordítás során ugyanis a szorzat.c-ből keletkező szorzat.o-ba is, és a main.c-ből fordított main.o-ba is bekerül a szorzat() függvény. Az előfordított szorzas.c és main.c ugyanis a lent látható formát öltik. Bemásolódott mindkettőbe a szorzas() függvény törzse, mert az #include-dal mintha copypaste-eltünk volna a fejlécfájlt mindkettőbe:

szorzas.c
előfordítva
int szorzat(int x, int y) {
    return x * y;
}
main.c
előfordítva
int szorzat(int x, int y) {
    return x * y;
}

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

A linker számára tehát úgy fog tűnni, hogy a függvény kétszer van definiálva, és leáll:

$ gcc szorzas.o main.o -o prg
main.o: In function `szorzat':
main.c:(.text+0x0): multiple definition of `szorzat'
szorzas.o:szorzas.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

Megvizsgálva a keletkezett object fájlokat, látjuk, hogy igaza is van; a szorzat() függvényből tényleg kettő lett az #include-ok miatt. Jogosan kiabál tehát a linker:

$ nm szorzas.o
0000000000000000 T szorzat

$ nm main.o
0000000000000013 T main
0000000000000000 T szorzat

Hova is jutottunk így? Ha szeretnénk, hogy a fordító inline-oljon, akkor a fejlécfájlba kell tennünk a függvénytörzset is. Ha viszont a fejlécfájlba tesszük, akkor viszont nem fordítható le a programunk, mert látszólag többször van definiálva a függvény.

Ennek az ellentmondásnak a feloldására való az inline kulcsszó. Ha ezt a fejlécfájlban a kifejtett függvény elé írjuk, akkor a fordító tudomására hozzuk, hogy emiatt látja többször a függvény törzsét:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

inline int szorzat(int x, int y) {
    return x * y;
}

#endif

Tehát ezzel nem azt kérjük a fordítótól, hogy inline-olja a függvényt, hanem csak azt jelezzük neki, hogy ennek a függvénynek a definíciójával (törzsével) valószínű többször is fog találkozni, de ez nem hiba. Szándékos részünkről, hogy az optimalizáció megtörténhessen.

Innentől kezdve pedig a C és a C++ kicsit máshogy kezelik a dolgot.

3. C++ nyelven

C++-ban egy inline függvény hatására a tárgykód fájlba egy ún. weak, „gyenge” szimbólum kerül:

$ g++ main.cpp -c -o main.o

$ nm -C main.o
0000000000000000 T main
0000000000000000 W szorzat(int, int)

Ez ugyanúgy egy lefordított függvénytörzset jelöl, mint amilyen a main is. De egy üzenet is egyben a linker számára, hogy ilyen nevű függvénnyel többször is fog találkozni, és a program linkelése során bármelyiket felhasználhatja. Ebben az esetben a programozó felel érte, hogy ne nevezzen el véletlenül különböző függvényeket ugyanúgy – ne feledjuk, az ODR (one definition rule) szabály továbbra is érvényes.

A recept ezek alapján C++-ban az inline használatához:

  • Ha inline-olni szeretnénk a függvényt, akkor tegyük át azt törzzsel együtt a fejlécfájlba.
  • A fejlécfájlban tegyük elé az inline kulcsszót.

És nincs több teendőnk. (Osztályok belsejében definiált függvények egyébként automatikusan inline-nak minősülnek, a sablonfüggvények pedig szintén.)

4. C nyelven

C-ben kicsit más a helyzet, ugyanis a C nyelvhez tartozó linker programokat nem akarták ezzel bonyolítani. Azok nem tudnak W típusú bejegyzéseket kezelni a tárgykód fájlokban. Ezért C-ben a programozó felel azért, hogy megjelölje valamelyik fordítási egységet, tehát valamelyik .c fájlt, amelybe a fordító bele fogja tenni a függvény szokásos, nem inline módon lefordított változatát. Ne feledjük: a függvény inline-olása optimalizációs kérdés, a fordító bármikor dönthet úgy, hogy nem teszi meg! Arról nem is beszélve, hogy inline függvényre is hivatkozhatunk pointerrel, márpedig akkor szokásos, önálló függvényként is szerepelnie kell valahol a memóriában.

A megjelölés az extern inline kulcsszavakkal történik:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

inline int szorzat(int x, int y) {
    return x * y;
}

#endif
szorzas.c
#include "szorzas.h"

extern inline int szorzat(int x, int y);

A tárgykód fájlok ilyen esetben így néznek ki:

$ nm szorzas.o
0000000000000000 T szorzat

$ nm main.o
0000000000000000 T main
                 U szorzat

A szorzas.o-ban tehát a szokásos módon szerepel a lefordított függvény. A többi helyen pedig, ahol nem inline-olódott, U, azaz undefined szimbólumként jelenik meg a függvény; ezt a hivatkozást majd feloldja a linker.

A recept C-ben:

  • Ha inline-olni szeretnénk a függvényt, akkor tegyük át azt, törzzsel együtt a fejlécfájlba.
  • A fejlécfájlban tegyük elé az inline kulcsszót.
  • Valamelyik forrásfájlban – tipikusan ott, ahol eredetileg a függvény volt – kérjük meg a fordítót a függvény szokásos lefordítására az extern inline kulcsszavakkal.