Skip to content

Case Study: A Portable C Library and C++ CLI

This case study connects the course's layers with one deliberately modest product. A C library parses integer samples and computes a summary. A C++ CLI owns input text, translates errors, and prints results. Tests exercise the installed public surface. The design is small enough to build manually before CMake automates it.

The assembled, buildable project is retained in case-studies/portable-report. The article develops its decisions in place; the retained project verifies that the complete form builds, installs, and works for an external consumer.

Start from the public C contract

The library accepts a pointer-plus-count view and returns status separately from output:

c
// include/report/report.h
#ifndef REPORT_REPORT_H
#define REPORT_REPORT_H

#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

enum report_status {
    REPORT_OK = 0,
    REPORT_EMPTY = 1,
    REPORT_INVALID_ARGUMENT = 2,
    REPORT_OVERFLOW = 3
};

struct report_summary {
    long long total;
    double mean;
    size_t count;
};

enum report_status report_summarize(
    const int *values,
    size_t count,
    struct report_summary *result
);

const char *report_status_message(enum report_status status);

#ifdef __cplusplus
}
#endif

#endif

The caller owns the input and output storage. A null values is valid only when count is zero; result is always required. No allocation crosses the ABI. The status enum is meaningful only for documented values.

Implement with checked accumulation

c
// src/report.c
#include "report/report.h"

#include <limits.h>

enum report_status report_summarize(
    const int *values,
    size_t count,
    struct report_summary *result
) {
    if (result == NULL || (values == NULL && count != 0)) {
        return REPORT_INVALID_ARGUMENT;
    }
    if (count == 0) {
        *result = (struct report_summary){0};
        return REPORT_EMPTY;
    }

    long long total = 0;
    for (size_t i = 0; i < count; ++i) {
        if ((values[i] > 0 && total > LLONG_MAX - values[i]) ||
            (values[i] < 0 && total < LLONG_MIN - values[i])) {
            return REPORT_OVERFLOW;
        }
        total += values[i];
    }

    *result = (struct report_summary){
        .total = total,
        .mean = (double)total / (double)count,
        .count = count,
    };
    return REPORT_OK;
}

const char *report_status_message(enum report_status status) {
    switch (status) {
        case REPORT_OK: return "success";
        case REPORT_EMPTY: return "no samples";
        case REPORT_INVALID_ARGUMENT: return "invalid argument";
        case REPORT_OVERFLOW: return "total overflow";
    }
    return "unknown report status";
}

The API distinguishes invalid arguments from empty input. Empty input is a modeled non-success result; the key is that callers need not inspect partially written output on failure.

The overflow check occurs before addition. Performing overflowing signed arithmetic and checking afterward would already have invoked undefined behavior.

Wrap the result for C++ consumers

cpp
// include/report/report.hpp
#pragma once

#include <report/report.h>

#include <span>
#include <stdexcept>

namespace report {

struct Summary {
    long long total;
    double mean;
    std::size_t count;
};

class Error final : public std::runtime_error {
public:
    explicit Error(report_status status)
        : std::runtime_error{report_status_message(status)}, status_{status} {}

    report_status status() const noexcept { return status_; }

private:
    report_status status_;
};

inline Summary summarize(std::span<const int> values) {
    report_summary result{};
    const auto status = report_summarize(values.data(), values.size(), &result);
    if (status != REPORT_OK) {
        throw Error{status};
    }
    return {result.total, result.mean, result.count};
}

} // namespace report

The header-only wrapper owns no resource. span represents a bounded borrow and the returned Summary is an ordinary value. Error::status() preserves the C error category while giving C++ callers exception-based propagation. Exceptions remain inside the C++ surface; none cross the C ABI.

Keep parsing at the application boundary

cpp
// app/main.cpp
#include <report/report.hpp>

#include <charconv>
#include <iostream>
#include <string_view>
#include <system_error>
#include <vector>

int main(int argc, char **argv) {
    std::vector<int> values;
    values.reserve(argc > 1 ? static_cast<std::size_t>(argc - 1) : 0);

    for (int i = 1; i < argc; ++i) {
        std::string_view text{argv[i]};
        int value{};
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), value);
        if (error != std::errc{} || end != text.data() + text.size()) {
            std::cerr << "invalid integer: " << text << '\n';
            return 2;
        }
        values.push_back(value);
    }

    try {
        const auto summary = report::summarize(values);
        std::cout << "count=" << summary.count
                  << " total=" << summary.total
                  << " mean=" << summary.mean << '\n';
        return 0;
    } catch (const std::exception& error) {
        std::cerr << "report: " << error.what() << '\n';
        return 1;
    }
}

The CLI validates complete integer tokens, writes successful output to stdout, diagnostics to stderr, and uses distinct statuses for invalid syntax versus domain failure. The library remains independent of process arguments and streams.

Build the artifacts manually

On a GCC-compatible Unix-like toolchain:

sh
mkdir -p build
cc -std=c17 -Iinclude -Wall -Wextra -Wpedantic -c src/report.c -o build/report.o
ar rcs build/libreport.a build/report.o
c++ -std=c++20 -Iinclude -Wall -Wextra -Wpedantic \
  app/main.cpp build/libreport.a -o build/report-cli
./build/report-cli 3 5 8

This makes the interface visible: the C header compiles in C, the wrapper compiles in C++, and the C++ driver supplies final runtime libraries.

Express the same graph in CMake

cmake
cmake_minimum_required(VERSION 3.25)
project(Report VERSION 1.0.0 LANGUAGES C CXX)

include(CTest)
include(GNUInstallDirs)

add_library(report src/report.c)
add_library(Report::report ALIAS report)
target_compile_features(report PUBLIC c_std_17)
target_include_directories(report PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)

add_library(report_cpp INTERFACE)
add_library(Report::report_cpp ALIAS report_cpp)
target_compile_features(report_cpp INTERFACE cxx_std_20)
target_link_libraries(report_cpp INTERFACE Report::report)

add_executable(report-cli app/main.cpp)
target_link_libraries(report-cli PRIVATE Report::report_cpp)

if(BUILD_TESTING)
  add_executable(report-tests tests/report_tests.c)
  target_link_libraries(report-tests PRIVATE Report::report)
  add_test(NAME report.unit COMMAND report-tests)
endif()

install(TARGETS report report_cpp report-cli
  EXPORT ReportTargets
  ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
  LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT ReportTargets
  FILE ReportTargets.cmake
  NAMESPACE Report::
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Report
)

A complete distributable package also needs ReportConfig.cmake and a version file. The retained case study includes both. The separate Report::report_cpp interface target carries the wrapper's C++20 requirement to consumers; the C target remains usable from C17 without inheriting C++ settings.

Add warning and sanitizer policy without leaking it

Warnings belong privately to first-party targets. A project helper can select flags by compiler; consumers should not inherit -Werror from an installed library.

Sanitizers form a development preset or option applied consistently to compiled and linked first-party targets. They are not part of the library's public usage requirements or release ABI.

Test through the C API

c
// tests/report_tests.c
#include <report/report.h>

int main(void) {
    const int values[] = {3, 5, 8};
    struct report_summary result;

    if (report_summarize(values, 3, &result) != REPORT_OK) return 1;
    if (result.total != 16 || result.count != 3) return 2;
    if (report_summarize(NULL, 0, &result) != REPORT_EMPTY) return 3;
    return 0;
}

Add CLI integration tests for exit codes and stream separation, then install into a staging prefix and configure a separate consumer. CI should cover GCC, Clang, MSVC for the C API, Apple Clang where macOS is supported, and at least one sanitizer job.

Follow each failure to its owner

  • report.h not found: target include usage or install layout.
  • report_summarize unresolved: link target/export/ABI.
  • DLL absent: runtime deployment/loader.
  • span unknown: C++ mode or library support.
  • integer rejected: application parsing policy.
  • overflow status: library domain policy.
  • consumer cannot find_package: missing/incorrect installed config metadata.

This classification is the central case-study result. Higher-level tooling did not remove the stages; it recorded their relationships.

Optional prompts

Explain: Why does the C API return status and write output through a pointer instead of throwing?

Answer: C has no exception mechanism, and exceptions must not cross a C ABI. The explicit status/output contract works from both languages and lets the C++ wrapper translate failures.

Modify: If the library begins allocating summaries, what new API obligation appears?

Answer: It must define ownership and provide a matching destruction function so allocation is released by the same library/runtime boundary.

Debug: In-tree tests pass, but an installed consumer cannot include report/report.h. Which validation was missing?

Answer: A staged install plus external consumer test. In-tree usage can see source include paths that exports or install rules failed to reproduce.

Further reference