Understanding Symbol Visibility in C++ Libraries

Symbol visibility in C++ controls which functions, classes, and variables are exposed in a shared library's dynamic symbol table versus which remain internal to the binary. By utilizing attributes like default and hidden, developers establish explicit Application Binary Interface (ABI) boundaries, preventing internal implementation details from leaking into consumer code. This article explains how visibility mechanisms work, why hidden symbols improve performance and safety, and how to configure visibility across modern C++ projects.

The Role of Symbol Visibility

When compiling code into dynamic shared libraries (.so on Linux or .dylib on macOS), the linker builds a dynamic symbol table (.dynsym). This table acts as the public interface, allowing external executables and libraries to resolve and bind references at runtime.

By default, Unix-like toolchains (GCC and Clang) export every non-static symbol. This behavior differs from Windows dynamic-link libraries (DLLs), which hide everything unless explicitly exported using __declspec(dllexport). Unrestricted export leads to symbol table bloat, slower load times, and potential name collisions across different libraries.

default vs. hidden Attributes

In GCC and Clang, visibility is controlled using the __attribute__((visibility("..."))) syntax:

  • default Visibility: The symbol is exported to the dynamic symbol table. External binaries can link against and call it at runtime. It can also be interposed (overridden) by other dynamic libraries unless protected or compiled with specific linker flags.
  • hidden Visibility: The symbol is omitted from the dynamic symbol table. It cannot be directly referenced or invoked by external modules. Within the shared object itself, the symbol remains accessible across translation units.
// Exported to the public interface
__attribute__((visibility("default"))) void public_api_function();

// Kept internal to the shared library
__attribute__((visibility("hidden"))) void internal_helper_function();

// Hiding an entire class implementation
class __attribute__((visibility("hidden"))) InternalEngine {
public:
    void process();
};

Inverting Defaults with Compiler Flags

Relying on explicit decoration to hide symbols is error-prone because developers must remember to tag every private implementation. Best practice dictates inverting the compiler's default behavior:

  1. -fvisibility=hidden: Treats all symbols as hidden unless explicitly marked with default visibility. This aligns GCC/Clang behavior with MSVC on Windows.
  2. -fvisibility-inlines-hidden: Automatically hides all inline class member functions, ensuring that out-of-line copies generated across translation units do not pollute the export table.

Establishing the Interface Boundary

Once -fvisibility=hidden is active, the public interface must be explicitly exported using preprocessor macros. This pattern ensures portable ABI boundaries across platforms:

#if defined(_WIN32) || defined(__CYGWIN__)
  #if defined(MYLIB_BUILDING_DLL)
    #define MYLIB_API __declspec(dllexport)
  #else
    #define MYLIB_API __declspec(dllimport)
  #endif
#else
  #if defined(__GNUC__) && __GNUC__ >= 4
    #define MYLIB_API __attribute__((visibility("default")))
  #else
    #define MYLIB_API
  #endif
#endif

// Only explicitly decorated entities form the public boundary
class MYLIB_API PublicService {
public:
    void execute();
private:
    void internalWork(); // Hidden automatically if the class is not fully exported
};

Benefits of Hiding Symbols

Hiding symbols behind an explicit boundary yields substantial architectural and performance benefits:

  • ABI Stability and Encapsulation: Implementation details remain private. You can refactor, rename, or delete hidden functions across library versions without breaking backward compatibility for external consumers.
  • Faster Execution: Calls to default symbols often require indirect lookups through the Procedure Linkage Table (PLT) and Global Offset Table (GOT) to accommodate runtime symbol interposition. Calls to hidden symbols can be resolved locally by the linker into direct PC-relative branch instructions, eliminating indirection overhead and enabling link-time inlining.
  • Reduced Binary Size and Startup Time: Omitting thousands of private functions, templates, and mangled names drastically shrinks .dynsym and .dynstr sections. At application launch, the dynamic loader (ld.so) processes far fewer relocations, resulting in faster load times.
  • Eliminating Symbol Collisions: If two loaded shared libraries statically link different versions of a third-party dependency with default visibility, the dynamic loader resolves both to the first loaded version, leading to undefined behavior or crashes. Marking dependencies as hidden keeps them isolated within their respective binaries.