To keep a native Mac plug-in’s bundled dependency private, compile its implementation with hidden visibility and export only the plug-in’s public entry points at the final link. Apply these controls to the binary that actually contains the dependency.
This guide is for a Mac desktop developer building a runtime-loaded plug-in that includes a C or C++ dependency. The concrete task is to retain the plug-in’s own implementation while exposing a small host-facing interface. A synthetic example builds a C dependency into a C++ plug-in and checks both its result and its exported symbols.
Which binary needs its exports limited?
Start by identifying where the dependency’s machine code ends up. A static archive supplies object files to the binary being linked; a dynamic library remains a separate image. Consequently, hiding symbols in a plug-in cannot rewrite the export table of a separate dependency dylib. Apple describes this distinction in its Dynamic Library Usage Guidelines.
| Dependency arrangement | Where to apply the control | What to verify |
|---|---|---|
| Dependency source compiled into the plug-in | Dependency compilation and final plug-in link | Private definitions are present but unexported |
| Static archive linked into the plug-in | Archive compilation when possible, plus final plug-in link | Required archive members were included; dependency exports are absent |
| Separate dependency dylib | The dependency dylib’s own build | Its public interface and the plug-in’s recorded imports |
| Dependency code instantiated from C++ headers | Every target compiling those headers | Emitted inline functions, templates, and associated data |
This table is a decision aid derived from those compilation and linking boundaries. For example, an export list attached to a wrapper target does not automatically hide symbols emitted into another target.
macOS also uses a two-level namespace by default: references resolved against a dylib record which library supplied them. Flat namespace changes that behavior. Weak external definitions introduce another consideration because the loader can coalesce them. Apple documents both mechanisms in its linker manual. Matching names alone therefore do not establish why a plug-in called an unexpected implementation.
Combine hidden compilation with an explicit export list
The compiler and linker controls act at different stages. Clang’s -fvisibility=hidden sets the default visibility of global definitions in the source files being compiled. It does not recompile an existing archive. Clang separately documents -fvisibility-inlines-hidden for inline C++ member functions; treat that as a targeted option whose suitability depends on the interface. See the Clang command-line reference.
Mark the intended public entry points with __attribute__((visibility("default"))). Visibility attributes can override the compilation default, so inspect dependency headers for explicit export annotations. Apple explains this behavior in Controlling Symbol Visibility.
At the final link, use -exported_symbols_list to enumerate the public interface. Apple’s documented export-list format uses linker symbol names, including the leading underscore for C symbols. Choose an export list or an unexported-symbol list rather than combining both strategies. See Dynamic Library Design Guidelines.
For a small plug-in interface, an explicit export list makes review straightforward: every exported name must have a reason to be public. Keep the source annotation and the list consistent; the annotation identifies the API in code, and the list constrains the final binary.
Build a complete local example
The example uses a C dependency deliberately: it demonstrates Mach-O export control without presenting incompatible C++ definitions as a portable language technique. The host’s dependency returns 10; the plug-in’s private build returns 20. These values are illustrative inputs.
Run the following blocks in order in the same Terminal shell on a Mac with Xcode Command Line Tools available. They create a fresh temporary directory and build for the active toolchain’s default architecture. The example was inspected but was not compiled or run during this research because the available environment was Linux without Apple’s toolchain.
Create the dependency and plug-in
set -eu
example_dir=$(mktemp -d)
cd "$example_dir"
cat > dependency.h <<'EOF'
#ifndef DEMO_DEPENDENCY_H
#define DEMO_DEPENDENCY_H
#ifdef __cplusplus
extern "C" {
#endif
int demo_dependency_value(void);
#ifdef __cplusplus
}
#endif
#endif
EOF
cat > dependency.c <<'EOF'
#include "dependency.h"
#ifndef DEP_VALUE
#error DEP_VALUE must be supplied at compilation
#endif
int demo_dependency_value(void) {
return DEP_VALUE;
}
EOF
cat > plugin.cpp <<'EOF'
#include "dependency.h"
extern "C" __attribute__((visibility("default")))
int demo_plugin_value(void) {
return demo_dependency_value();
}
EOF
cat > exports.txt <<'EOF'
_demo_plugin_value
EOF
The dependency header gives the C++ wrapper the correct C-language linkage declaration. The public wrapper also uses C linkage, which keeps this entry point’s linker name simple. C++ names generally require mangling; Apple discusses that distinction in Controlling Symbol Visibility.
Create the host
The host loads the plug-in and obtains its public function through the returned library handle. Apple documents dlopen support for both dynamic libraries and Mach-O bundles. Its RTLD_LOCAL option limits the availability of the loaded image’s exported symbols; it does not replace build-time visibility controls. See Apple’s dlopen manual.
cat > host.cpp <<'EOF'
#include "dependency.h"
#include <cstdio>
#include <dlfcn.h>
int main() {
void* handle = dlopen("./DemoPlugin.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) {
std::fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
dlerror();
void* address = dlsym(handle, "demo_plugin_value");
const char* error = dlerror();
if (error || !address) {
std::fprintf(stderr, "dlsym: %s\n",
error ? error : "null function address");
dlclose(handle);
return 1;
}
using PluginValue = int (*)(void);
PluginValue plugin_value = reinterpret_cast<PluginValue>(address);
const int host_result = demo_dependency_value();
const int plugin_result = plugin_value();
std::printf("host=%d plugin=%d\n", host_result, plugin_result);
const bool correct = host_result == 10 && plugin_result == 20;
if (dlclose(handle) != 0) {
std::fprintf(stderr, "dlclose: %s\n", dlerror());
return 1;
}
return correct ? 0 : 2;
}
EOF
Compile and link
# Build the plug-in's dependency with hidden definitions.
xcrun clang -fvisibility=hidden -DDEP_VALUE=20 \
-c dependency.c -o plugin_dependency.o
xcrun ar rcs libPluginDependency.a plugin_dependency.o
# Compile the public wrapper, then constrain the final exports.
xcrun clang++ -std=c++17 -fvisibility=hidden \
-c plugin.cpp -o plugin.o
xcrun clang++ -dynamiclib plugin.o libPluginDependency.a \
-Wl,-exported_symbols_list,exports.txt \
-o DemoPlugin.dylib
# Give the host a separately compiled implementation.
xcrun clang -DDEP_VALUE=10 -c dependency.c -o host_dependency.o
xcrun clang++ -std=c++17 host.cpp host_dependency.o \
-Wl,-exported_symbol,_demo_dependency_value -o host
./host
The expected output is host=10 plugin=20. The host explicitly exports its dependency symbol so that the example includes an externally visible implementation with the same C name. The plug-in must still contain its private implementation.
This is a verification example, not a claim that removing the controls necessarily makes the result fail. Default Mach-O binding can already distinguish definitions in many arrangements. The useful checks are the selected implementation and the final export surface.
Verify exports and imports separately
Run these commands in the example directory:
xcrun nm -g -U -j DemoPlugin.dylib
xcrun dyld_info -exports DemoPlugin.dylib
xcrun dyld_info -imports DemoPlugin.dylib
xcrun otool -L DemoPlugin.dylib
In the first command, -g selects external symbols, -U excludes undefined symbols, and -j prints names. For this minimal plug-in, the expected defined external name is _demo_plugin_value; _demo_dependency_value should be absent. Apple’s nm manual documents these options and explains that symbol-table information can differ from the information used by the loader.
Use dyld_info -exports to inspect loader-facing exports and -imports to inspect imported symbols. The dependency function should appear in neither list: it is implemented privately inside this plug-in. These inspection options are documented in Apple’s dyld_info manual.
Use otool -L to check the actual dylib dependencies. The static archive should not appear as a separate runtime dependency. Apple documents this dependency inspection in Dynamic Library Usage Guidelines.
For a real plug-in, keep three questions separate: Is the required implementation included? Is it exported? Does any call still import a competing implementation? A successful build answers none of those questions by itself.
Apply the same controls in Xcode
For the target producing the final plug-in binary, set Exported Symbols File (EXPORTED_SYMBOLS_FILE) to the project-relative path of the export list. Enable Symbols Hidden by Default (GCC_SYMBOLS_PRIVATE_EXTERN) on the C and C++ targets that compile the private implementation. Apple’s current Build settings reference documents both settings.
If the dependency is a separate static-library target, configure that target too. Changing only the plug-in’s compiler settings leaves previously compiled dependency objects untouched. Rebuild the affected targets and inspect the resulting product for each architecture you distribute.
For a prebuilt archive, the final export list remains useful. Apple’s published linker manual also documents -load_hidden for treating an archive’s globals as hidden while using normal archive extraction. That manual is for ld-classic; check the installed linker’s documentation before choosing this additional option. -force_load controls archive-member inclusion, which is a separate issue. See the Apple linker manual.
Preserve C++ and Swift interface requirements
C++ visibility does not repair incompatible definitions
The C++ one-definition rule places requirements on definitions across translation units, including matching-token and name-lookup requirements where multiple definitions are permitted. Hiding a symbol is not permission to combine conflicting C++ definitions. For independently versioned implementations, use distinct namespaces or symbol names where feasible. See the C++ working draft’s one-definition rule.
Also preserve the visibility needed by an intentional C++ interface. Apple identifies cross-library RTTI, exceptions, dynamic casts, inline-function address identity, and shared static objects as cases requiring care. A practical design is to keep dependency types and ownership inside the plug-in and expose a small interface using agreed scalar values or opaque handles. See Controlling Symbol Visibility.
Swift import access controls a different boundary
Swift’s internal import restricts where imported declarations may be referenced. SE-0409, implemented in Swift 6.0, recommends replacing uses of @_implementationOnly import with an internal import or lower. It does not define a Mach-O dependency-renaming or export-filtering mechanism. Continue to configure and inspect the native binaries separately. See Access-level modifiers on import declarations.
If using direct Swift/C++ interoperability, retain its toolchain constraints: support begins with Swift 5.9, mixed code must use the same C++ standard library, and Apple platforms use libc++. Imported C++ reference types also impose a macOS 13.3 minimum deployment version. Export filtering changes none of these requirements. See Supported Features and Constraints of C++ Interoperability.
A plug-in is ready for integration testing when its intended entry points remain available, its private dependency definitions are absent from loader exports, and its imports match the libraries it is supposed to use. Keep those checks alongside a behavioral test like the synthetic example so that later dependency updates reveal both interface changes and implementation-selection changes.
Sources
- Build settings reference — Apple.
- Dynamic Library Design Guidelines — Apple.
- Dynamic Library Usage Guidelines — Apple.
- Controlling Symbol Visibility — Apple.
- Clang command line argument reference — LLVM Project.
- ld-classic(1) manual — Apple.
- dlopen(3) manual — Apple.
- nm(1) manual — Apple.
- dyld_info(1) manual — Apple.
- One-definition rule — C++ working draft, rendered by Tim Song.
- Access-level modifiers on import declarations — Swift Project.
- Supported Features and Constraints of C++ Interoperability — Swift.org.