07 · Cross-platform Development¶
"Portable C++" is not a property of the standard; it is a property of the code
you wrote and the CI matrix you run it on. The standard leaves a large number of
things implementation-defined on purpose — the size of long, whether char is
signed, how paths are spelled — and every one of those is a place where code
that works everywhere you tested it fails somewhere you didn't.
The strategy has three parts: use the standard library instead of platform APIs, isolate what remains behind a thin interface, and build on every target in CI. The last one is not optional. Portability that isn't compiled is a hypothesis.
What actually varies¶
#include <bit>
#include <climits>
#include <iostream>
int main() {
std::cout << "sizeof: int=" << sizeof(int) << " long=" << sizeof(long)
<< " long long=" << sizeof(long long) << " void*=" << sizeof(void*)
<< " size_t=" << sizeof(std::size_t) << "\n";
std::cout << "CHAR_BIT=" << CHAR_BIT << ", char is "
<< (CHAR_MIN < 0 ? "signed" : "unsigned") << " by default\n";
std::cout << "endian: " << (std::endian::native == std::endian::little
? "little" : "big") << "\n";
}
sizeof: int=4 long=8 long long=8 void*=8 size_t=8
CHAR_BIT=8, char is signed by default
endian: little
That's macOS on ARM64. On 64-bit Windows, long is 4 bytes, not 8 —
the LLP64 model versus the LP64 model used by Linux and macOS. Code that assumes
sizeof(long) == sizeof(void*) is broken on Windows; code that assumes
sizeof(long) == 4 is broken everywhere else.
char is signed on x86 Linux and macOS, and unsigned on ARM Linux and
several embedded targets. char c = getchar(); if (c == EOF) therefore behaves
differently by architecture — one of the oldest portability bugs there is.
The fixes are simple and mechanical:
#include <cstdint>
std::int32_t x; // exactly 32 bits, everywhere
std::int64_t y; // exactly 64 bits, everywhere
std::uintptr_t p; // big enough to hold a pointer
std::size_t n; // the type of sizeof and container sizes
Use <cstdint> types whenever the width is part of the meaning — file
formats, network protocols, hardware registers. Use std::size_t and
std::ptrdiff_t for sizes and offsets. Reserve plain int for small local
arithmetic where you genuinely don't care.
Detecting platform and compiler¶
const char* platform() {
#if defined(_WIN32) // defined on 32- AND 64-bit Windows
return "Windows";
#elif defined(__APPLE__)
return "macOS/iOS";
#elif defined(__linux__)
return "Linux";
#else
return "unknown";
#endif
}
const char* compiler() {
#if defined(__clang__) // MUST come first -- Clang also defines __GNUC__
return "Clang";
#elif defined(_MSC_VER)
return "MSVC";
#elif defined(__GNUC__)
return "GCC";
#else
return "unknown";
#endif
}
The ordering comment is the important part. Clang defines __GNUC__ for
compatibility, and clang-cl defines _MSC_VER. Check the most specific
compiler first or your "GCC branch" will silently apply to Clang.
Feature test macros — check for features, not versions¶
Version checks like #if __GNUC__ >= 11 are guesses about which release
shipped which feature. The standard provides a direct answer:
#include <version> // C++20: all library feature-test macros
#ifdef __cpp_lib_ranges
// std::ranges is available
#else
// fall back to iterator pairs
#endif
The value is a date (YYYYMM) of the paper the implementation supports, so you
can require a specific revision: #if __cpp_lib_ranges >= 202106. Language
features have __cpp_* macros (e.g. __cpp_concepts) with no header needed.
std::filesystem — the biggest portability win in the standard¶
Paths were the classic portability tax: separators, drive letters, case
sensitivity, UTF-16 filenames on Windows. <filesystem> (C++17) handles all of
it.
#include <filesystem>
namespace fs = std::filesystem;
fs::path p = fs::path("data") / "logs" / "app.log"; // operator/ picks the separator
std::cout << "joined path: " << p << "\n";
std::cout << " parent: " << p.parent_path() << ", stem: " << p.stem()
<< ", ext: " << p.extension() << "\n";
std::cout << "preferred separator: '" << char(fs::path::preferred_separator) << "'\n";
std::cout << "temp dir: " << fs::temp_directory_path() << "\n";
joined path: "data/logs/app.log"
parent: "data/logs", stem: "app", ext: ".log"
preferred separator: '/'
temp dir: "/var/folders/ly/vrxx_3s90hj1gj22sf7pklsw0000gn/T/"
On Windows the same code prints "data\\logs\\app.log" and '\'. Never build a
path with string concatenation and a hard-coded '/'; use operator/. And
never hard-code /tmp — fs::temp_directory_path() consults TMPDIR,
%TEMP%, or the platform default.
Other essentials: fs::exists, fs::create_directories, fs::file_size,
fs::directory_iterator, fs::recursive_directory_iterator, fs::rename,
fs::remove_all, fs::absolute, fs::weakly_canonical.
Isolating what the standard doesn't cover¶
Dynamic library loading, memory mapping, process spawning, terminal control and
high-resolution sleeps have no standard API. The pattern is a narrow
interface with per-platform implementations, not #ifdefs scattered through
business logic:
src/
platform/platform.h # one declaration per operation
platform/platform_posix.cpp # implementation, POSIX
platform/platform_win32.cpp # implementation, Windows
if(WIN32)
target_sources(app PRIVATE src/platform/platform_win32.cpp)
else()
target_sources(app PRIVATE src/platform/platform_posix.cpp)
endif()
The rest of the program includes platform.h and contains no #ifdef at all.
That is the difference between code with two implementations and code with two
hundred conditional branches — the second kind cannot be reasoned about or
tested.
CMake as the portability layer¶
if(MSVC)
target_compile_options(app PRIVATE /W4 /permissive- /utf-8 /EHsc)
target_compile_definitions(app PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN
_CRT_SECURE_NO_WARNINGS)
else()
target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic)
endif()
find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads) # -pthread, or nothing on Windows
NOMINMAX is not optional if you include <windows.h>: it defines min and
max as macros that break std::min, std::numeric_limits<T>::max(), and
anything else with those names. WIN32_LEAN_AND_MEAN cuts a large amount of
header. /permissive- turns on MSVC's conforming mode — without it, MSVC
accepts non-standard code that then fails on GCC and Clang.
find_package(Threads) plus Threads::Threads is the portable way to say
-pthread; hard-coding the flag breaks on MSVC.
A CI matrix is the only real proof¶
# .github/workflows/ci.yml
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
build_type: [Debug, Release]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- run: cmake -S . -B build -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
- run: cmake --build build --config ${{ matrix.build_type }}
- run: ctest --test-dir build -C ${{ matrix.build_type }} --output-on-failure
Six configurations, three compilers, two standard-library implementations
(libstdc++, libc++, MSVC STL). fail-fast: false matters — you want to see
all the platform failures at once, not the first one.
Text: encodings and line endings¶
Open files in binary mode (std::ios::binary) unless you specifically want
text translation. On Windows, text mode converts \n to \r\n on write and
back on read, which silently corrupts binary data and makes byte offsets and
file sizes disagree across platforms.
For filenames and user-visible text, prefer UTF-8 everywhere in your own code
and convert only at the Windows API boundary (MultiByteToWideChar, or just use
fs::path, which stores the native encoding and converts for you). Add
/utf-8 to MSVC so it stops assuming the system codepage for source files.
Cheat sheet¶
| Concern | Portable answer |
|---|---|
| Fixed-width integers | <cstdint>: int32_t, uint64_t, uintptr_t |
| Sizes and offsets | std::size_t, std::ptrdiff_t |
long is 4 bytes on Windows |
Never assume; use int64_t |
char signedness varies |
Cast to unsigned char before <cctype> functions |
| Byte order | std::endian::native; convert explicitly at I/O boundaries |
| Paths | std::filesystem::path and operator/ |
| Temp / home directories | fs::temp_directory_path(), never /tmp |
| Feature availability | <version> + __cpp_lib_* macros |
| Compiler detection order | __clang__ → _MSC_VER → __GNUC__ |
| Threads | <thread> + find_package(Threads) / Threads::Threads |
| Windows macro pollution | NOMINMAX, WIN32_LEAN_AND_MEAN |
| MSVC conformance | /permissive- /utf-8 /EHsc /W4 |
| Platform-specific code | Separate .cpp per platform, selected in CMake |
| Proof of portability | GitHub Actions matrix over 3 OSes × 2 build types |
Traps¶
#ifdef __GNUC__ catching Clang. Clang sets __GNUC__. Order your checks
from most specific to least.
std::endl where '\n' was meant. std::endl flushes the stream every
time — this is a performance bug, not a portability one, but it hides here
because people reach for it thinking it handles line endings. It does not; use
'\n'.
Text-mode file I/O on Windows. \n → \r\n translation corrupts binary
data and shifts every offset. Pass std::ios::binary.
std::tolower(c) with a plain char. If char is signed and the byte is
non-ASCII, the value is negative, and passing a negative value other than EOF
to <cctype> functions is undefined behaviour. Always
std::tolower(static_cast<unsigned char>(c)).
Case-insensitive filesystems. macOS (by default) and Windows treat
Config.h and config.h as the same file; Linux does not. A wrong-case
#include compiles on two of your three CI machines. This is one of the most
common cross-platform build failures, and only a Linux job catches it.
Mixing standard-library implementations or build flags across binaries.
libstdc++ and libc++ have different, incompatible ABIs for std::string. So do
Debug and Release MSVC runtimes. Every binary in a process must be built with
the same toolchain and runtime settings.
std::filesystem needs a link flag on older toolchains. GCC 8 and Clang 8
require -lstdc++fs / -lc++fs. CMake's find_package(Filesystem) module or a
version check handles it; a bare #include <filesystem> may configure fine and
fail at link.
Assuming hardware_concurrency() is nonzero. It is permitted to return 0
when the count is unknown. Clamp it: std::max(1u, std::thread::hardware_concurrency()).
How It Actually Works¶
Implementation-defined behavior exists because the standard specifies
C++'s semantics, not a specific hardware encoding — int is only
guaranteed to be at least 16 bits, long at least 32, precisely so the
language maps efficiently onto CPUs with genuinely different native word
sizes; the compiler picks the actual bit width based on what's efficient
for the target ABI (Level 1 Module 4's calling-convention concept extends to
type sizes too), which is why sizeof(long) differs between 64-bit Linux
(8 bytes) and 64-bit Windows (4 bytes) for the exact same source code — a
real, silent source of bugs when serializing raw structs across platforms
or over a network without an explicit, fixed-width format.
Endianness is a hardware fact about how a multi-byte value's bytes are
ordered in memory: little-endian CPUs (x86, most ARM configurations) store
the least-significant byte at the lowest address, so reading the 4 raw bytes
of an int and reinterpreting them on a big-endian machine produces a
completely different number — this is exactly why network protocols
mandate a specific byte order ("network byte order," big-endian) and why
htons/ntohs-style conversion functions exist: they perform an explicit
byte-swap so serialized data round-trips correctly regardless of which
architecture wrote or reads it.
#ifdef _WIN32 / #ifdef __APPLE__ platform guards work through the same
preprocessor mechanism from Level 1 Module 1 — each compiler predefines a
set of macros identifying its target platform, and the preprocessor
literally deletes the code inside a non-matching #ifdef branch before the
compiler proper ever sees it, so platform-specific code (a POSIX read()
call versus a Windows ReadFile() call) never even needs to type-check on
platforms where it wouldn't compile — a CI matrix running one build per
platform is what actually verifies each of those deleted-elsewhere branches
compiles correctly somewhere.
Exercise¶
Make the task processor genuinely portable and prove it.
- Add a
platform/layer with one function —std::size_t availableMemoryMb()— declared inplatform.hand implemented twice:sysconf(_SC_PHYS_PAGES)on POSIX,GlobalMemoryStatusExon Windows. Select the.cppin CMake withif(WIN32). No#ifdefmay appear outsideplatform/. - Replace every hard-coded path in the demo with
std::filesystem::pathoperations, and write its log tofs::temp_directory_path() / "taskproc.log"opened withstd::ios::binary. - Use
std::max(1u, std::thread::hardware_concurrency())for the worker count and print it. - Add a
<version>-guarded fast path: if__cpp_lib_jthreadis defined usestd::jthread, otherwise fall back tostd::threadplus an explicit join loop. Verify both branches compile by forcing the fallback with a temporary#undef-style test build. - Add a GitHub Actions matrix over
ubuntu-latest,macos-latestandwindows-latest×Debug/Release, withfail-fast: false, runningctest. Get all six green.
Step 5 will fail at least once for a reason you did not predict — that is the lesson, and it is why the matrix exists.