<< Home

Chapter 32: Case Studies and Mini Projects

This chapter ties together the core ideas from earlier chapters by building several small but realistic projects. Each case study is scoped to fit in a few pages; each also includes notes on testing, packaging, and trade offs. The goal is to show how modern C++ features can reduce boilerplate and improve clarity when used with care.

A header only library with concepts

Header only libraries are easy to consume and distribute because they do not require a separate link step for users. Concepts let you write readable constraints so that template errors surface as clear diagnostics. We will design a tiny numeric utilities library that works for integral and floating point types and that rejects types which do not meet the required operations.

Layout and naming

Keep the public entry point small and stable. Prefer a single umbrella header that includes internal headers. Avoid macros other than include guards; prefer #pragma once if your toolchain supports it.

// include/numx/numx.hpp
#pragma once
#include <concepts>
#include <type_traits>
#include <limits>

namespace numx {

template<typename T>
concept Arithmetic = std::is_arithmetic_v<T>;

template<Arithmetic T>
constexpr auto clamp(T v, T lo, T hi) -> T {
  return v < lo ? lo : (v > hi ? hi : v);
}

template<Arithmetic T, std::convertible_to<T> U>
constexpr auto lerp(T a, T b, U t) -> T {
  return static_cast<T>((T{1} - static_cast<T>(t)) * a + static_cast<T>(t) * b);
}

} // namespace numx
πŸ’‘ Use narrow concepts that describe behavior, not names. Prefer std::regular, std::semiregular, std::sortable, and similar traits when they match your needs.

Testing the interface

Even header only libraries benefit from tests that compile quickly. Use simple unit tests to lock in behavior and error messages where possible.

// tests/numx_tests.cpp
#include "numx/numx.hpp"
#include <cassert>
int main() {
  using numx::clamp;
  assert(clamp(42, 0, 10) == 10);
  assert(clamp(-1, 0, 10) == 0);
  static_assert(noexcept(numx::clamp(1, 0, 10)) == false);
}

Documentation in headers

Small comments at the point of use help readers and tools. Favor short summaries above declarations and longer notes in user guides. Keep examples tiny and focused.

⚠️ Avoid non inline variables or odr violations in headers. If you need static storage, prefer inline variables or function local statics when appropriate.

A coroutine based async pipeline

Coroutines express asynchronous workflows as straightforward code that suspends and resumes. We will sketch a toy pipeline that reads items from a source, transforms them, and writes them to a sink. The example uses a minimal scheduler; production code would integrate with an event loop or thread pool.

Design sketch

The pipeline consists of three parts: a source that produces values, a transform that applies work, and a sink that consumes values. Each stage returns an awaitable type with the right promise_type so the compiler can manage suspension points.

// coro/pipeline.hpp
#pragma once
#include <coroutine>
#include <optional>
#include <queue>

template<typename T>
struct generator {
  struct promise_type {
    std::optional<T> current;
    generator get_return_object() noexcept { return generator{std::coroutine_handle<promise_type>::from_promise(*this)}; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    std::suspend_always yield_value(T v) noexcept { current = std::move(v); return {}; }
    void unhandled_exception() { throw; }
    void return_void() {}
  };
  std::coroutine_handle<promise_type> h;
  ~generator(){ if(h) h.destroy(); }
  bool next(){ if(!h.done()) { h.resume(); return !h.done(); } return false; }
  T& value(){ return *h.promise().current; }
};

generator<int> source(int n){
  for(int i = 0; i < n; ++i) co_yield i;
}

generator<int> transform(generator<int> in){
  while(in.next()){
    int v = in.value();
    co_yield v * v;
  }
}

Consuming with co_await

Simple generators do not require co_await to iterate; they use co_yield internally. If you build asynchronous I/O tasks, your awaitables must implement the awaiter protocol with await_ready, await_suspend, and await_resume…

// coro/main.cpp
#include "pipeline.hpp"
#include <iostream>
int main(){
  auto out = transform(source(5));
  while(out.next()){
    std::cout << out.value() << "\n";
  }
}
πŸ’‘ Start with synchronous generators to understand control flow. Add a scheduler and real co_await only when you introduce I/O or timers.

A small CLI with modules and CMake

Modern C++ modules reduce compile time and improve encapsulation. A small command line tool shows how to structure modules and build them with CMake. The tool will read a file, count lines, words, and bytes, and print a summary similar to classic utilities.

Module files

Place your interface units where consumers can find them and keep implementation details in internal partitions if needed. The syntax may vary slightly across compilers; check your toolchain’s module flags.

// src/text.util.ixx
export module text.util;
export namespace text {
  struct counts { long lines; long words; long bytes; };
  counts measure(const char* path);
}
// src/text.util.cpp
module;
#include <cstdio>
#include <cctype>

export module text.util;
export namespace text {
  counts measure(const char* path){
    std::FILE* f = std::fopen(path, "rb");
    if(!f) return {0,0,0};
    long lines = 0, words = 0, bytes = 0;
    int c = 0, prev = ' ';
    while((c = std::fgetc(f)) != EOF){
      ++bytes;
      if(c == '\n') ++lines;
      if(!std::isspace(c) && std::isspace(prev)) ++words;
      prev = c;
    }
    std::fclose(f);
    return {lines, words, bytes};
  }
}

CMake build

Enable languages and set the standard. Recent CMake versions understand module dependency scanning for popular compilers. Keep the target small; expose only what callers need.

# CMakeLists.txt
cmake_minimum_required(VERSION 3.29)
project(tuc LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)

add_executable(tuc
  src/text.util.ixx
  src/text.util.cpp
  src/main.cpp)

target_compile_features(tuc PRIVATE cxx_std_23)
// src/main.cpp
import text.util;
#include <iostream>

int main(int argc, char** argv){
  if(argc < 2){ std::cout << "usage: tuc <file>\n"; return 1; }
  auto r = text::measure(argv[1]);
  std::cout << r.lines << " " << r.words << " " << r.bytes << "\n";
}
⚠️ Module support is still evolving across platforms. If you target older compilers, provide a fallback header that declares the same API and switch with build options.

A range based data processor

Ranges let you express data flows as composable views. We will parse a CSV like stream, filter rows, transform columns, and aggregate results using standard algorithms. The example keeps parsing simple so that the focus remains on the pipeline.

Parsing and views

Represent each record as a small struct and use std::views to slice, filter, and transform. When performance matters, benchmark alternatives before committing to an approach.

// src/pipeline.cpp
#include <string>
#include <vector>
#include <ranges>
#include <charconv>
#include <iostream>

struct row { std::string name; int score; };

static row parse(std::string_view s){
  auto p = s.find(',');
  row r;
  r.name = std::string(s.substr(0, p));
  int v = 0; std::from_chars(s.data() + p + 1, s.data() + s.size(), v);
  r.score = v;
  return r;
}

int main(){
  std::vector<std::string> lines = {"Ada,90","Bjarne,95","Grace,99","Linus,88"};
  auto rng =
    lines
    | std::views::transform(parse)
    | std::views::filter([](const row& r){ return r.score >= 90; })
    | std::views::transform([](const row& r){ return r.score; });

  int total = 0;
  for(int v : rng) total += v;

  std::cout << "total: " << total << "\n";
}
πŸ’‘ Use std::views::as_rvalue or move aware transforms when large strings or vectors flow through the pipeline and you own them.

Error handling strategy

For production parsing, propagate failures with std::expected<T, E> or a similar type. Keep the hot path free of exceptions if you measure that it helps your workload; the right choice depends on data and platform.

Packaging and distribution

After a project compiles and tests pass, package artifacts so others can use them with minimal friction. Choose an approach that matches your audience. Libraries may ship source packages; CLIs may provide prebuilt binaries; both can register with package managers to reduce setup time.

Source and binary packages

Create tarballs or zip files that contain a top level directory, a license, a short readme, and either include/ headers or bin/ executables. Keep paths stable so scripts can automate updates. Provide a simple cmake --build recipe or a Makefile for portability.

Registries and managers

For C++ libraries, consider vcpkg and Conan. For system packages, provide Debian and RPM specs or Homebrew formulas. Each ecosystem has small differences; document the supported platforms and any caveats clearly.

// conanfile.py … minimal example with name, version, exports, and package() …
⚠️ Do not forget reproducibility. Pin exact versions for dependencies, record the compiler and standard library, and capture your build flags in CI logs so that others can rebuild the same bits.

Versioning and changelogs

Follow semantic versioning when possible. Maintain a human friendly CHANGELOG.md at the root and keep entries short. Readers want to know what changed, why it changed, and how to migrate if there are breaking changes.