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-hiddenThis 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
- 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. - 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.
- 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.
- 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.