C++ Symbol Visibility and Export Attributes

This article examines how symbol visibility attributes, particularly [[gnu::visibility]], govern the exposure of functions, classes, and variables in dynamic shared libraries in C++. By taking control of the export table, developers can hide internal implementation details, reduce dynamic library load times, and improve compiler optimizations. Below, we break down the mechanics of the visibility attribute, its interaction with compiler flags, and the architectural benefits of explicit symbol management.

Default Symbol Exposure in C++

When building Dynamic Shared Objects (DSOs) or shared libraries on UNIX-like systems using GCC or Clang, the default behavior is to export every non-static symbol. Every function, global variable, and non-inlined class method is placed into the library's dynamic symbol table.

This behavior contrasts with Windows DLLs, where symbols are private by default unless explicitly exported using __declspec(dllexport). Leaving all symbols public in ELF binaries leads to symbol collisions, bloated binary sizes, and longer startup times due to runtime symbol relocation.

The Syntax of [[gnu::visibility]]

Modern C++ (C++11 and later) standardizes attribute syntax using double square brackets [[...]]. Compilers like GCC and Clang support visibility controls within the gnu vendor namespace:

// Exported API function
[[gnu::visibility("default")]] void public_api_function();

// Explicitly hidden internal function
[[gnu::visibility("hidden")]] void internal_helper_function();

// Entire class exported
class [[gnu::visibility("default")]] ExportedClass {
public:
    void exported_method();
    [[gnu::visibility("hidden")]] void internal_method();
};
  • "default": The symbol is exported to the dynamic symbol table and can be referenced by external modules.
  • "hidden": The symbol is omitted from the dynamic symbol table. It cannot be directly linked or called from outside the shared object, though it can still be used internally across translation units.

Best Practice: Inverting the Default with -fvisibility=hidden

The primary utility of the visibility attribute comes from pairing it with compiler flags. Instead of manually hiding internal symbols, developers typically compile the entire project with:

-fvisibility=hidden -fvisibility-inlines-hidden

This flag instructs the compiler to treat all symbols as hidden by default, mimicking the behavior of Windows PE binaries. Developers then explicitly annotate only the public-facing API with [[gnu::visibility("default")]].

A typical cross-platform macro setup looks like this:

#if defined(_WIN32) || defined(__CYGWIN__)
    #if defined(BUILDING_MY_LIB)
        #define MY_API __declspec(dllexport)
    #else
        #define MY_API __declspec(dllimport)
    #endif
#else
    #if defined(__GNUC__) && __GNUC__ >= 4
        #define MY_API [[gnu::visibility("default")]]
    #else
        #define MY_API
    #endif
#endif

MY_API void run_service();

Key Advantages of Controlling Symbol Visibility

  1. Faster Dynamic Loading: When an application starts, the dynamic linker (ld.so) must resolve imported symbols against loaded libraries. A smaller dynamic symbol table significantly speeds up program startup.
  2. Enhanced Compiler Optimizations: When the compiler knows a symbol cannot be overridden or called externally, it can make direct calls instead of going through the Procedure Linkage Table (PLT). It can also aggressively inline, dead-code-strip, or optimize code layout.
  3. Strict API Boundaries: Hiding private symbols prevents consumers from accidentally depending on internal implementation details, preventing unexpected Application Binary Interface (ABI) breaks during library updates.
  4. Avoidance of Symbol Collisions: If two separate dependencies contain an identically named internal function (e.g., init_buffer()), marking them as hidden prevents the dynamic linker from mistakenly binding both libraries to a single implementation.