36.2 extern C és két nyelvű header

A C++-ban írt könyvtár egyik legértékesebb tulajdonsága lehet, hogy C fogyasztók számára is használható marad. Ez a DevOps-gyakorlatban különösen fontos: a C ABI a legstabilabb közös nevező a nyelvek, a shared library határok és a különböző build-rendszerek között. A C++ oldalon megmaradhat a típusos, RAII-alapú implementáció, miközben a publikált felület szándékosan szűk, C-kompatibilis és hosszú távon verziózható marad. Ez az alfejezet a kétnyelvű header és az extern "C" határ szerződését tárgyalja; a névtorzítás és a C++ runtime ABI általános kérdéseit Névtorzítás, layout és C++ runtime ABI (36.1), a stabil shared library határt és verziózást Stabil shared library határ és verziózás (36.3), a Python FFI-kapcsolatot Python és más nyelvek FFI-kapcsolata (36.4), a laborellenőrzést pedig ABI- és FFI-labor, kompatibilitási teszt (36.5) tárgyalja.

36.2.1 A nyelvi linkage és a kétnyelvű header szerződése

A C++ standardban a függvények és külső nevek language linkage-gel rendelkeznek. Az extern "C" nyelvi kapcsolás azt írja elő, hogy a deklarált függvény a C nyelvi kapcsolás szabályai szerint legyen elérhető. A gyakorlatban ez legtöbbször a C++ névtorzítás (name mangling) kikapcsolását és a platform C hívási konvenciójának használatát jelenti. Fontos, hogy az extern "C" nem változtatja meg a függvény szignatúráját: a paraméterek és a visszatérési típus ugyanaz marad, csak a külső név és a hívási szerződés lesz C-kompatibilis. Az osztályok, sablonok, overloadok és referenciák nem válnak automatikusan C-kompatibilissé attól, hogy egy extern "C" blokk közelében vannak.

A kétnyelvű header két őrt használ. A header guard (#ifndef PARSER_H) a többszörös include ellen véd, a #ifdef __cplusplus pedig azt biztosítja, hogy az extern "C" blokk csak C++ fordításban jelenjen meg. A C fordító nem definiálja a __cplusplus makrót, a C++ fordító viszont igen; C++20-ban az értéke jellemzően 202002L. A helyes minta:

#ifndef PARSER_H
#define PARSER_H

#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

/* C-kompatibilis deklarációk */

#ifdef __cplusplus
}
#endif

#endif

A headerben csak olyan deklarációk szerepelhetnek, amelyeket a C fordító is elfogad: typedef, struct tag, enum, függvénydeklaráció, const char *, size_t, void. Kerülni kell a bool-t (C17-ben csak <stdbool.h>-val, C23-ban kulcsszó), a nullptr-t, az auto-t, a referenciákat, a default argumenteket és a noexcept-ot; utóbbit makróval lehet C++ módban noexcept-ká tenni. A C nem tud C++ osztályt, sablont vagy overloadot fogyasztani, mert a C szintaxis nem ismeri ezeket, a C++ ABI pedig ezekhez nem ad C-kompatibilis nevet. Ezért a közös felület csak függvényekből, opaque pointerekből, C-kompatibilis structokból és enumokból állhat.

Az opaque handle lényege, hogy a C oldal csak egy előre deklarált struct pointerét látja, a méretet és a layoutot nem. A typedef struct parser_ctx parser_ctx; deklaráció C és C++ módban is működik; a struct definíciója a C++ implementációban marad. A C oldal így nem tud sizeof(parser_ctx)-t venni, nem tud stacken példányt létrehozni, és nem tud a belső tagokhoz hozzáférni. Az életciklust explicit create/destroy függvények kezelik; ez az Opaque handle és stabil C könyvtári interfész (10.4) mintájára épül.

36.2.2 A C parserkönyvtár headerje

A példa egy egyszerű parser könyvtár. A header a teljes nyilvános szerződés; a C++ implementáció részletei elrejthetők. A PARSER_API makró a dinamikus exportálhatóságot jelöli, a PARSER_NOEXCEPT pedig C++ módban noexcept-ot ad. A PARSER_BUILD_SHARED makrót a könyvtár fordításakor definiáljuk; Windowson ez dönti el, hogy dllexport vagy dllimport legyen.

#ifndef PARSER_H
#define PARSER_H

#include <stddef.h>

#if defined(_WIN32)
#  if defined(PARSER_BUILD_SHARED)
#    define PARSER_API __declspec(dllexport)
#  else
#    define PARSER_API __declspec(dllimport)
#  endif
#elif defined(__GNUC__) || defined(__clang__)
#  define PARSER_API __attribute__((visibility("default")))
#else
#  define PARSER_API
#endif

#ifdef __cplusplus
#  define PARSER_NOEXCEPT noexcept
extern "C" {
#else
#  define PARSER_NOEXCEPT
#endif

typedef struct parser_ctx parser_ctx;

typedef enum parser_status {
    PARSER_OK = 0,
    PARSER_EINVAL = 1,
    PARSER_ENOMEM = 2,
    PARSER_ELIMIT = 3,
    PARSER_ESYNTAX = 4
} parser_status;

PARSER_API parser_ctx *parser_create(void) PARSER_NOEXCEPT;
PARSER_API void parser_destroy(parser_ctx *ctx) PARSER_NOEXCEPT;
PARSER_API parser_status parser_feed(parser_ctx *ctx,
                                     const char *data,
                                     size_t len) PARSER_NOEXCEPT;
PARSER_API parser_status parser_finish(parser_ctx *ctx) PARSER_NOEXCEPT;
PARSER_API const char *parser_version(void) PARSER_NOEXCEPT;

#ifdef __cplusplus
}
#endif

#endif

A PARSER_API és a PARSER_NOEXCEPT makrók a header guardon belül vannak, így egyszer definiálódnak. A PARSER_NOEXCEPT a C oldalon üres, a C++ oldalon noexcept; a C++ hívó így tudja, hogy a függvény nem dob. A parser_version statikus élettartamú stringre mutat, ezért a C hívó nem szabadítja fel.

A visibility és a linkage két külön dolog. Az extern "C" a név és a hívási konvenció szintjén teremt C-kompatibilitást; a visibility azt szabályozza, hogy a szimbólum bekerül-e a dinamikus szimbólumtáblába. Ha -fvisibility=hidden mellett fordítunk, minden szimbólum rejtett lesz, kivéve a visibility("default")-tal jelölteket. Az extern "C" önmagában nem tesz egy szimbólumot exportálttá. A static függvény belső linkage-ű, és nem exportált; ezért a könyvtár belső segédfüggvényeit static-nak vagy névtelen névtérbe zártnak kell tenni.

36.2.3 A C++ implementáció és a határ szerződései

A C++ implementációban a parser_ctx teljes definíciója szerepel, a C oldal számára láthatatlanul. A std::string és a bool csak a C++ oldalon jelenik meg. A parser_create new-val allokál, a parser_destroy delete-tel szabadít; a C hívó soha nem hívhat free-t a pointerre. Ez az allocator pairing alapja: a memóriát az a modul szabadítja fel, amelyik allokálta, ugyanazzal az allocatorral. Windows DLL-határon ez különösen kritikus, mert a DLL és az EXE eltérő CRT-heapet használhat.

#include "parser.h"

#include <cstddef>
#include <new>
#include <stdexcept>
#include <string>

struct parser_ctx {
    std::string input;
    bool finished = false;
};

extern "C" PARSER_API parser_ctx *parser_create(void) PARSER_NOEXCEPT {
    try {
        return new parser_ctx();
    } catch (...) {
        return nullptr;
    }
}

extern "C" PARSER_API void parser_destroy(parser_ctx *ctx) PARSER_NOEXCEPT {
    delete ctx;
}

extern "C" PARSER_API parser_status
parser_feed(parser_ctx *ctx, const char *data, size_t len) PARSER_NOEXCEPT {
    if (ctx == nullptr) {
        return PARSER_EINVAL;
    }
    if (data == nullptr) {
        return len == 0 ? PARSER_OK : PARSER_EINVAL;
    }
    try {
        ctx->input.append(data, len);
        return PARSER_OK;
    } catch (const std::bad_alloc &) {
        return PARSER_ENOMEM;
    } catch (const std::length_error &) {
        return PARSER_ELIMIT;
    } catch (...) {
        return PARSER_ESYNTAX;
    }
}

extern "C" PARSER_API parser_status
parser_finish(parser_ctx *ctx) PARSER_NOEXCEPT {
    if (ctx == nullptr) {
        return PARSER_EINVAL;
    }
    try {
        ctx->finished = true;
        return PARSER_OK;
    } catch (...) {
        return PARSER_ESYNTAX;
    }
}

extern "C" PARSER_API const char *parser_version(void) PARSER_NOEXCEPT {
    return "1.0";
}

A try/catch minden extern "C" függvényben kötelező elem, ha a C hívó felé nem akarunk C++ kivételt engedni. A C hívó nem tud try/catch-et használni; a C ABI-n nincs kivételkezelési protokoll. A catch (...) a legvégső háló; a konkrétabb catch-ek hibakódot adnak. A noexcept ezt a szándékot erősíti: ha mégis kijutna egy kivétel, a C++ runtime std::terminate-et hív. A parser_destroy destruktora nem dobhat; a std::string destruktora ezt biztosítja. A noexcept határok és hibapolicy (25.3) rész a noexcept és a hibapolicy általános kérdéseit tárgyalja; itt a C határra alkalmazzuk.

A parser_feed a bemenetet másolja, nem tárolja a C hívó pointerét. Így a C hívó a data puffert a hívás után felszabadíthatja. A parser_finish a példában nem ad vissza belső pointert; a 2. feladatban majd igen, és ott a pointer élettartamát a szerződésben rögzíteni kell. A parser_ctx élettartama alatt a belső buffer érvényes, de a következő módosító hívás invalidálhatja. A C hívó nem szabadíthatja fel a belső buffert.

A C fogyasztó a headert C módban includes; a NULL és a strlen a C könyvtárból jön. A C++ fogyasztó ugyanazt a headert használja; a extern "C" miatt a hívás C linkage-ű. A C++ fogyasztó a parser_ctx-et nem tudja delete-elni, csak a parser_destroy-t hívhatja.

#include "parser.h"
#include <stdio.h>
#include <string.h>

int main(void) {
    parser_ctx *ctx = parser_create();
    if (ctx == NULL) {
        fputs("parser_create failed\n", stderr);
        return 1;
    }

    const char *chunk = "int x = 42;";
    parser_status st = parser_feed(ctx, chunk, strlen(chunk));
    if (st != PARSER_OK) {
        fprintf(stderr, "feed failed: %d\n", (int)st);
        parser_destroy(ctx);
        return 2;
    }

    st = parser_finish(ctx);
    parser_destroy(ctx);
    return st == PARSER_OK ? 0 : 3;
}
#include "parser.h"
#include <cstring>
#include <iostream>

int main() {
    parser_ctx *ctx = parser_create();
    if (!ctx) {
        return 1;
    }
    const char *chunk = "int x = 42;";
    parser_status st = parser_feed(ctx, chunk, std::strlen(chunk));
    parser_destroy(ctx);
    if (st != PARSER_OK) {
        std::cerr << "feed failed: " << static_cast<int>(st) << '\n';
        return 2;
    }
    return 0;
}

36.2.4 Build és ellenőrzés

A könyvtárat C++20-cal, a fogyasztókat C17-tel és C++20-cal fordítjuk. A -fPIC a shared libraryhoz kell, a -fvisibility=hidden a rejtett szimbólumokhoz, a PARSER_BUILD_SHARED a Windows export makróhoz. A C fogyasztó nem linkel -lstdc++-t; a libparser.so dinamikus függőségeként a C++ runtime betöltődik. Statikus linkelésnél ezt külön kell kezelni.

# C++ könyvtár
g++ -std=c++20 -fPIC -fvisibility=hidden -DPARSER_BUILD_SHARED -I. \
    -c parser.cpp -o parser.o
g++ -shared -o libparser.so parser.o

# C fogyasztó
gcc -std=c17 -I. -c consumer.c -o consumer.o
gcc consumer.o -L. -Wl,-rpath,'$ORIGIN' -lparser -o consumer

# C++ fogyasztó
g++ -std=c++20 -I. -c consumer.cpp -o consumer_cpp.o
g++ consumer_cpp.o -L. -Wl,-rpath,'$ORIGIN' -lparser -o consumer_cpp

# Ellenőrzés
nm -D --defined-only libparser.so | grep parser_
./consumer
./consumer_cpp

Az nm -D a dinamikus szimbólumtáblát mutatja. Ha a parser_create helyett _Z13parser_createv-t látunk, akkor az extern "C" hiányzik vagy nem érvényesül. Ha a parser_create egyáltalán nem látszik, akkor a visibility rejtett, vagy a PARSER_API hiányzik. A c++filt a mangled nevek visszafejtésére használható. A readelf -Ws libparser.so | grep parser_ a verziózott szimbólumokat is megmutatja. A konkrét kimenet target- és toolchainfüggő; a példa a szerkezetet illusztrálja, nem állítja, hogy a fenti parancsok eredményét itt ellenőriztük.

Fogalom Mit szabályoz Hol jelenik meg Tipikus hiba
language linkage név és hívási konvenció extern "C" hiányzik: mangled név, linkelési hiba
visibility dinamikus szimbólumtábla PARSER_API, -fvisibility=hidden hiányzik: nem exportált szimbólum
allocator pairing ki szabadítja fel a memóriát create/destroy free a new pointerére
exception boundary kivétel átlépése a C határon try/catch, noexcept kivétel a C hívó felé
opaque handle layout elrejtése előre deklarált struct a struct definíciója a headerben

36.2.5 Tipikus hibák és javításuk

A leggyakoribb hiba, hogy a közös headerbe C++-os elem kerül: std::string, class, template, bool, nullptr vagy referencia. A C fordító ezt nem érti; a header nem lesz kétnyelvű. A javítás: a közös header csak C-kompatibilis deklarációkat tartalmaz, a C++ részletek a parser.cpp-be kerülnek.

A második hiba az extern "C" hiánya. A C++ fordító mangled nevet generál, a C linker nem találja a parser_create szimbólumot. A javítás: a deklaráció és a definíció is legyen extern "C" hatókörben; a headerben a #ifdef __cplusplus blokk, a definíciónál explicit extern "C".

A harmadik hiba a visibility összekeverése a linkage-pel. Az extern "C" önmagában nem exportál; -fvisibility=hidden mellett a PARSER_API kell. A javítás: a nyilvános függvények deklarációján és definícióján is legyen PARSER_API, és a build definiálja a PARSER_BUILD_SHARED makrót ott, ahol kell.

A negyedik hiba a kivétel átengedése a C határon. A C hívó nem tudja elkapni; a viselkedés nem hordozható. A javítás: minden extern "C" függvényben try/catch, hibakód visszaadása, és a noexcept tudatos használata.

Az ötödik hiba az allocator mismatch: a C oldal free-vel szabadítja a C++ new pointerét, vagy fordítva. A javítás: a parser_destroy végezze a felszabadítást; a C oldal ne hívjon free-t a parser_ctx*-re. Windows DLL-határon a memóriát abban a modulban kell felszabadítani, amelyik allokálta, vagy közös allocator API-t kell használni.

A hatodik hiba az opaque handle megsértése: a struct parser_ctx definíciója bekerül a közös headerbe. Ettől a C oldal látja a layoutot, és a könyvtár ABI-változása esetén a C fogyasztó újrafordítása nélkül is elromolhat. A javítás: a headerben csak typedef struct parser_ctx parser_ctx; szerepeljen.

A hetedik hiba a parser_feed(nullptr, 0) hívása. A std::string::append(nullptr, 0) nem hordozható, mert a pointerre vonatkozó előfeltétel sérülhet. A javítás: a nullptr és a nulla hossz külön kezelendő, ahogy a példában.

36.2.6 Feladatok

  1. Alapfeladat. Add hozzá a parser_version függvényt a headerhez és az implementációhoz úgy, hogy C és C++ módban is forduljon, és a visszaadott pointer statikus élettartamú legyen. Ellenőrizd, hogy a nm -D mutatja a parser_version szimbólumot, és hogy a C fogyasztó ki tudja írni az értékét.

  2. Közepes feladat. Módosítsd a parser_finish függvényt úgy, hogy egy parser_result structot adjon vissza, amely tartalmaz egy const char *data pointert, egy size_t len mezőt és egy parser_status status mezőt. A data a parser_ctx belső bufferére mutasson, és a parser_ctx élettartamáig legyen érvényes; a C hívó ne szabadítsa fel. A parser_ctx továbbra is opaque maradjon. Írd le a pointer élettartamára vonatkozó szerződést, és készíts C fogyasztót, amely kiírja a resultot.

  3. Haladó feladat. Készíts Parser nevű C++ RAII wrappert a C API köré. A wrapper egy std::unique_ptr-rel tartsa a parser_ctx*-et, saját deleterrel, amely a parser_destroy-t hívja. A feed és finish hibakódját alakítsd std::system_error-ré. A wrapper soha ne engedjen kivételt a C API felé. Írj rövid C++ tesztet, amely ellenőrzi a sikeres és a hibás feed viselkedését, valamint azt, hogy a wrapper destruktora felszabadítja az erőforrást.

36.2.7 Megoldások

36.2.7.1 1. feladat megoldása

A headerben a deklaráció a többi függvény mellett:

PARSER_API const char *parser_version(void) PARSER_NOEXCEPT;

Az implementációban:

extern "C" PARSER_API const char *parser_version(void) PARSER_NOEXCEPT {
    return "1.0";
}

A string literál statikus élettartamú, ezért a pointer a program teljes futása alatt érvényes; a C hívó nem szabadítja fel. A C fogyasztóban:

printf("parser version: %s\n", parser_version());

Az ellenőrzés feltétele: a header C17 és C++20 módban is fordul; a nm -D --defined-only libparser.so | grep parser_version mutatja a szimbólumot; a C fogyasztó helyesen írja ki az 1.0-t. A PARSER_NOEXCEPT a C oldalon üres, a C++ oldalon noexcept; a függvény nem dob.

36.2.7.2 2. feladat megoldása

A headerben a parser_finish deklarációja és a result struct:

typedef struct parser_result {
    const char *data;
    size_t len;
    parser_status status;
} parser_result;

PARSER_API parser_result parser_finish(parser_ctx *ctx) PARSER_NOEXCEPT;

Az implementációban:

extern "C" PARSER_API parser_result
parser_finish(parser_ctx *ctx) PARSER_NOEXCEPT {
    parser_result r{nullptr, 0, PARSER_EINVAL};
    if (ctx == nullptr) {
        return r;
    }
    try {
        ctx->finished = true;
        r.data = ctx->input.data();
        r.len = ctx->input.size();
        r.status = PARSER_OK;
        return r;
    } catch (...) {
        r.status = PARSER_ESYNTAX;
        return r;
    }
}

A parser_result C-kompatibilis struct; a C++ oldalon aggregate inicializálható. A data a ctx->input belső bufferére mutat, ezért a parser_ctx élettartamáig érvényes, de a következő parser_feed vagy parser_finish hívás invalidálhatja. A C hívó a resultot addig használhatja, amíg a parser_ctx-et nem módosítja és nem destruálja. A C fogyasztó:

parser_result r = parser_finish(ctx);
if (r.status == PARSER_OK) {
    fwrite(r.data, 1, r.len, stdout);
}
parser_destroy(ctx);

Az ellenőrzés feltétele: a parser_result layoutja a C és C++ oldalon ugyanaz; a parser_finish nem dob; a data pointer a parser_ctx élettartamához kötött; a C hívó nem hív free-t a data-ra.

36.2.7.3 3. feladat megoldása

A wrapper a C API fölé épül, a C API-t változatlanul hagyja:

#include "parser.h"

#include <cstddef>
#include <memory>
#include <new>
#include <system_error>

namespace {

struct parser_deleter {
    void operator()(parser_ctx *p) const noexcept {
        parser_destroy(p);
    }
};

} // namespace

class Parser {
public:
    Parser() : ctx_(parser_create()) {
        if (!ctx_) {
            throw std::bad_alloc();
        }
    }

    Parser(const Parser &) = delete;
    Parser &operator=(const Parser &) = delete;
    Parser(Parser &&) noexcept = default;
    Parser &operator=(Parser &&) noexcept = default;

    void feed(const char *data, std::size_t len) {
        parser_status st = parser_feed(ctx_.get(), data, len);
        if (st != PARSER_OK) {
            throw std::system_error(static_cast<int>(st),
                                    std::generic_category(),
                                    "parser_feed");
        }
    }

    void finish() {
        parser_status st = parser_finish(ctx_.get());
        if (st != PARSER_OK) {
        throw std::system_error(static_cast<int>(st),
                                std::generic_category(),
                                "parser_finish");
        }
    }

private:
    std::unique_ptr<parser_ctx, parser_deleter> ctx_;
};

A parser_deleter noexcept, a parser_destroy is az; a unique_ptr destruktora így nem dob. A Parser copy tiltott, move engedélyezett. A feed és finish kivételt dob a C++ hívó felé, de ez már a C++ oldalon történik; a C API határán a try/catch és a hibakód véd. A std::system_error a parser_status int értékét használja; a std::generic_category nem ad specifikus üzenetet, de a hibakódot hordozza. A teszt:

#include "parser.h"
#include <cassert>
#include <string>

int main() {
    Parser p;
    const std::string chunk = "int x = 42;";
    p.feed(chunk.data(), chunk.size());
    p.finish();

    bool threw = false;
    try {
        p.feed(nullptr, 1);
    } catch (const std::system_error &) {
        threw = true;
    }
    assert(threw);
    return 0;
}

Az ellenőrzés feltétele: a wrapper destruktora a parser_destroy-t hívja; a sikeres feed nem dob; a nullptr, 1 bemenet PARSER_EINVAL-t ad, amelyet a wrapper std::system_error-ré alakít; a C API felé nem lép ki kivétel. A Parser move műveletei a unique_ptr miatt működnek; a copy tiltása megakadályozza a dupla felszabadítást.

36.2.8 Összefoglaló

A kétnyelvű header lényege, hogy a közös felület C-kompatibilis maradjon: header guard, #ifdef __cplusplus körüli extern "C" blokk, opaque handle, C-kompatibilis structok és enumok. Az extern "C" a nyelvi linkage-et állítja be, a visibility és az export makrók ettől függetlenül szabályozzák a dinamikus szimbólumtáblát. A C++ implementáció a try/catch-csel és hibakódokkal védi a C határt; a noexcept tudatos használata erősíti a szerződést. Az allocator pairing szerint a memóriát az allokáló modul szabadítja fel, a create/destroy páros ezt biztosítja. A C oldal soha nem lát C++ osztályt, sablont vagy overloadot; a C++ oldal belső részletei a shared library határ mögött maradnak. A build és az nm/readelf ellenőrzés a linkage és a visibility hibáit különíti el; a konkrét ABI- és szabványhivatkozásokat a laborprofilban kell ellenőrizni.