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:
defaultVisibility: 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.hiddenVisibility: 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:
-fvisibility=hidden: Treats all symbols ashiddenunless explicitly marked withdefaultvisibility. This aligns GCC/Clang behavior with MSVC on Windows.-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
defaultsymbols often require indirect lookups through the Procedure Linkage Table (PLT) and Global Offset Table (GOT) to accommodate runtime symbol interposition. Calls tohiddensymbols 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
.dynsymand.dynstrsections. 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
hiddenkeeps them isolated within their respective binaries.