C++

C++는 C (프로그래밍 언어)를 기반으로 비야네 스트롭스트룹이 설계한 범용 프로그래밍 언어이자 시스템 프로그래밍 언어다. 운영체제와 장치 드라이버처럼 하드웨어에 가까운 소프트웨어부터 게임 엔진, 데스크톱 애플리케이션, 웹 브라우저, 데이터베이스, 금융 시스템과 과학 계산 프로그램까지...

분류범용 프로그래밍 언어, 시스템 프로그래밍 언어
패러다임절차형 프로그래밍, 객체 지향 프로그래밍, 제네릭 프로그래밍, 함수형 프로그래밍
설계자비야네 스트롭스트룹
개발 시작1979년
최초 상용 공개1985년
기반 언어C (프로그래밍 언어)
타입 체계정적 타입, 명목적 타입, 타입 추론
메모리 관리자동 저장 기간, 수동 동적 메모리 관리, RAII
현재 표준ISO/IEC 14882:2024 (C++23)
표준화 기구ISO/IEC JTC 1/SC 22/WG 21
주요 구현체GCC, Clang, Microsoft Visual C++
파일 확장자.cpp, .cc, .cxx, .hpp, .h

C++C (프로그래밍 언어)를 기반으로 비야네 스트롭스트룹이 설계한 범용 프로그래밍 언어이자 시스템 프로그래밍 언어다. 운영체제와 장치 드라이버처럼 하드웨어에 가까운 소프트웨어부터 게임 엔진, 데스크톱 애플리케이션, 웹 브라우저, 데이터베이스, 금융 시스템과 과학 계산 프로그램까지 성능과 자원 제어가 중요한 분야에서 사용된다.

C++는 C의 저수준 메모리 접근과 효율적인 실행 모델을 계승하면서 클래스, 상속, 함수와 연산자 오버로딩, 템플릿, 예외 처리, 이름공간과 표준 라이브러리 등의 기능을 추가했다. 이후 이동 의미론, 람다 표현식, 컴파일 시간 계산, 개념, 범위, 모듈과 코루틴 등이 도입되면서 절차형·객체 지향·제네릭·함수형 프로그래밍을 하나의 언어 안에서 조합할 수 있는 다중 패러다임 언어로 발전했다.

프로그램은 객체의 저장 공간과 수명, 값의 복사와 이동, 메모리 배치 및 하드웨어 자원에 직접 관여할 수 있다. 동시에 생성자와 소멸자를 객체 수명에 결합하는 RAII와 표준 컨테이너, 스마트 포인터 및 알고리즘을 이용해 자원 관리를 추상화할 수도 있다. 이러한 구조는 실행 비용을 숨기지 않으면서도 고수준 추상화를 구성한다는 C++의 설계 방향과 연결된다.

C++의 개발은 1979년 Bell Labs에서 시작되었다. 초기 언어는 C with Classes라고 불렸으며, 1983년부터 C++라는 이름이 사용되었다. 최초의 상용 구현과 《The C++ Programming Language》 초판은 1985년에 공개되었다.[1] 1998년 최초의 국제 표준이 제정된 뒤 C++03, C++11, C++14, C++17, C++20과 C++23으로 개정되었으며, 현재 발행된 표준은 ISO/IEC 14882:2024로 규정된 C++23이다.[2][3]

역사

C++의 역사는 C (프로그래밍 언어)의 실행 효율과 시스템 프로그래밍 능력을 유지하면서, 대규모 프로그램을 더 체계적으로 구성할 수 있는 추상화 기능을 추가해 온 과정이다. 최초의 언어는 C with Classes라는 이름으로 개발되었으며, 이후 독립된 컴파일러와 가상 함수, 함수 및 연산자 오버로딩이 추가되면서 C++로 발전했다. 1998년 국제 표준이 처음 제정된 뒤에는 표준 라이브러리와 제네릭 프로그래밍이 언어의 중심으로 자리 잡았고, C++11 이후에는 이동 의미론, 람다 표현식, 컴파일 시간 계산과 동시성 기능을 포함하는 현대 C++ 체계가 형성되었다.

역사적 코드 예제는 각 시대의 대표적인 문법과 프로그래밍 방식을 보여주기 위해 작성되었다. C with Classes 예제는 당시 문헌에 나타난 문법을 바탕으로 재구성한 것이며, 오늘날의 C++ 컴파일러에서 그대로 컴파일되는 코드는 아니다.

C with Classes의 개발

비야네 스트롭스트룹은 1979년 Bell Labs에서 분산 시스템과 네트워크 관련 프로그램을 개발하면서, C의 효율성과 Simula의 클래스 기반 프로그램 구성 방식을 결합하려 했다. 그는 같은 해 10월 Simula와 유사한 클래스 기능을 C에 추가하는 전처리기 Cpre를 만들었고, 1980년 3월에는 실제 프로젝트와 여러 실험에서 사용할 수 있는 수준으로 발전시켰다.[4]

이 초기 언어는 별도의 독립 언어라기보다 C에 클래스를 추가한 확장으로 인식되었으며, C with Classes라고 불렸다. 구현 방식도 완전한 컴파일러가 아니라 C with Classes 코드를 일반 C 코드로 변환한 뒤 기존 C 컴파일러에 전달하는 전처리기 방식이었다.

C with Classes의 초기 컴파일 과정
  1. C with Classes 소스 코드
  2. Cpre 전처리기
  3. 일반 C 소스 코드
  4. C 컴파일러
  5. 기계어 또는 실행 파일

초기 C with Classes에는 클래스, 파생 클래스, publicprivate 접근 제어, 생성자와 소멸자, 친구 클래스, 함수 인자의 타입 검사 등이 포함되었다. 1981년에는 인라인 함수, 기본 인자와 대입 연산자 오버로딩도 추가되었다. 반면 가상 함수, 함수 오버로딩, 참조, 템플릿과 예외 처리는 아직 존재하지 않았다.[5]

당시 클래스는 오늘날과 마찬가지로 데이터와 그 데이터를 다루는 함수를 하나의 사용자 정의 타입으로 묶는 역할을 했다. 그러나 생성자 문법과 객체 선언 방식에는 현대 C++와 다른 부분이 있었다.

다음 코드는 1980년 C with Classes의 stack 예제를 바탕으로 당시 문법을 재구성한 것이다.

#define SIZE 100

class stack {
    char data[SIZE];
    char* bottom;
    char* top;
    char* limit;

    // 당시에는 클래스 이름과 같은 함수가 아니라
    // new()라는 특별한 초기화 함수를 생성자로 사용했다.
    void new();

public:
    void push(char value);
    char pop();
};

당시의 new()는 동적 메모리 할당 연산자가 아니라 객체 초기화 함수, 즉 오늘날의 생성자에 해당했다. C with Classes 문헌에서는 이를 new-function이라고 불렀다. 이후 C++에서는 생성자의 이름을 클래스 이름과 같게 작성하는 현재 방식으로 변경되었다.

멤버 함수는 클래스 선언 바깥에서 정의할 수 있었지만, 현재 사용하는 stack::pop() 형태가 아니라 점 표기법과 유사한 문법이 사용되었다.

char stack.pop()
{
    if (top <= bottom) {
        error("stack underflow");
    }

    return *(--top);
}

객체를 선언할 때도 타입 이름 앞에 class를 명시하는 형태가 일반적으로 사용되었다.

class stack first;
class stack second;

class stack* pointer = &second;

// 당시의 new는 객체 생성 및 동적 할당에도 사용되었지만,
// 문법과 내부 의미는 현대 C++와 완전히 같지 않았다.
class stack* dynamic_stack = new stack;

first.push('A');
pointer->push('B');

이 코드에서 확인할 수 있는 초기 설계 원칙은 이후 C++에도 대부분 유지되었다.

  • 클래스는 단순한 코드 묶음이 아니라 객체를 생성할 수 있는 타입이다.
  • 객체의 데이터 배치는 클래스 선언에서 결정된다.
  • 비공개 데이터에는 클래스의 멤버 함수만 접근할 수 있다.
  • 멤버 함수의 선언과 정의를 분리할 수 있다.
  • 객체는 포인터뿐 아니라 지역 변수와 전역 변수로도 선언할 수 있다.
  • C의 포인터와 배열, 수동 메모리 접근 기능을 그대로 사용할 수 있다.

C with Classes의 목적은 C의 계산 모델을 대체하는 것이 아니었다. 계산과 하드웨어 제어는 이미 C가 해결한 문제로 보고, 클래스와 접근 제어를 통해 프로그램의 구조를 개선하는 데 초점을 맞췄다. 스트롭스트룹은 실행 시간, 코드 크기와 객체 크기에서 C보다 불필요한 비용이 발생하지 않아야 한다는 기준을 적용했다.[6]

C++라는 이름과 최초 공개

1982년 무렵 스트롭스트룹은 C with Classes의 전처리기 기반 구조와 제한된 기능만으로는 더 넓은 사용자를 지원하기 어렵다고 판단했다. 그는 기존 경험을 바탕으로 언어를 정리하고 확장하면서, 전처리기가 아닌 전통적인 컴파일러 구조를 사용하는 새 구현체를 개발하기 시작했다.

새 언어는 잠시 C84라고 불렸으나, 1983년부터 C++라는 이름이 사용되었다. 이름은 C의 증가 연산자 ++에서 가져온 것으로, C를 기반으로 한 다음 단계의 언어라는 의미를 나타낸다. C with Classes라는 이름이 일상적으로 C로 줄여 불리면서 기존 C가 “old C” 또는 “plain C”로 불리는 혼란이 생긴 것도 이름 변경의 배경이었다.[7]

초기 C++에는 C with Classes에 없던 다음과 같은 기능이 추가되었다.

  • 가상 함수
  • 함수 이름과 연산자 오버로딩
  • 참조
  • const
  • // 한 줄 주석
  • 자유 저장 영역 연산자 newdelete
  • 클래스 이름을 사용하는 생성자와 ~로 시작하는 소멸자
  • 범위 지정 연산자 ::

C with Classes의 스택을 초기 C++ 문법으로 옮기면 다음과 같은 형태가 된다.

const int stack_size = 100;

class Stack {
    char data[stack_size];
    char* bottom;
    char* top;
    char* limit;

public:
    // 클래스 이름과 같은 함수가 생성자가 되었다.
    Stack();

    // 물결표가 붙은 함수는 소멸자가 되었다.
    ~Stack();

    void push(char value);
    char pop();
};

Stack::Stack()
{
    bottom = data;
    top = data;
    limit = data + stack_size;
}

Stack::~Stack()
{
    // 이 클래스는 외부 자원을 소유하지 않으므로
    // 별도로 해제할 작업이 없다.
}

char Stack::pop()
{
    if (top <= bottom) {
        error("stack underflow");
    }

    return *(--top);
}

이 시기부터 Stack::Stack()처럼 ::를 사용해 클래스 외부에서 멤버 함수를 정의하는 문법이 정착했다. 생성자는 클래스 이름과 같고 반환형을 갖지 않으며, 소멸자는 클래스 이름 앞에 ~를 붙이는 현재의 기본 구조가 만들어졌다.

가상 함수의 추가는 C++가 단순한 추상 자료형 언어에서 객체 지향 프로그래밍 언어로 확장되는 중요한 계기가 되었다.

class Shape {
public:
    virtual void draw();
};

class Circle : public Shape {
public:
    void draw();
};

void render(Shape& shape)
{
    // 실제 객체가 Circle이면 Circle::draw()가 호출된다.
    shape.draw();
}

C with Classes에도 파생 클래스는 있었지만 가상 함수가 없었기 때문에, 기반 클래스의 인터페이스를 통해 실제 파생 객체의 동작을 선택하는 런타임 다형성을 직접 표현하기 어려웠다. 1983년 가상 함수가 추가되면서 클래스 계층과 동적 디스패치를 결합한 객체 지향 프로그래밍이 가능해졌다.

1985년에는 최초의 상용 C++ 구현체인 Cfront 1.0과 스트롭스트룹의 《The C++ Programming Language》 초판이 공개되었다. Cfront는 C++ 코드를 C 코드로 변환했지만, 단순한 문자 기반 전처리기가 아니라 C++ 문법과 타입 체계를 분석하는 컴파일러 프런트엔드였다.[8]

Cfront의 컴파일 과정
  1. C++ 소스 코드
  2. Cfront 구문 및 타입 분석
  3. 변환된 C 소스 코드
  4. 플랫폼의 C 컴파일러
  5. 실행 파일

C++ 2.0과 언어 기능의 확장

1989년에 공개된 C++ 2.0은 다중 상속, 추상 클래스, static 멤버 함수, const 멤버 함수와 보호된 접근 수준인 protected 등을 도입했다. 이 시기의 C++는 대규모 객체 지향 프로그램을 작성하기 위한 기능을 본격적으로 갖추기 시작했다.

class Drawable {
public:
    virtual void draw() = 0;
};

class Serializable {
public:
    virtual void save() = 0;
};

class Document : public Drawable, public Serializable {
public:
    void draw();
    void save();
};

= 0으로 선언된 순수 가상 함수와 추상 클래스는 공통 인터페이스를 표현할 수 있게 했다. 다중 상속은 하나의 클래스가 여러 기반 클래스의 인터페이스와 구현을 함께 계승할 수 있게 했지만, 기반 클래스의 중복과 복잡한 객체 배치 문제도 함께 가져왔다.

이 시기에는 템플릿과 예외 처리도 실험되고 있었다. 1990년에 출판된 The Annotated C++ Reference Manual, 줄여서 ARM은 당시 AT&T C++ 2.0을 바탕으로 언어를 정리했으며, 이후 표준화 작업의 주요 기반 문서가 되었다. ARM과 초기 표준화 과정에서 템플릿과 예외 처리가 언어의 정식 기능으로 자리 잡았다.[9]

초기 템플릿은 서로 다른 타입에 대해 같은 자료구조나 알고리즘을 반복 작성하는 문제를 해결했다.

template<class T>
class Stack {
    T* data;
    int size;
    int top;

public:
    Stack(int capacity);
    ~Stack();

    void push(T value);
    T pop();
};

템플릿을 사용하면 Stack<int>, Stack<double>Stack<MyType>처럼 타입별 스택을 하나의 정의에서 생성할 수 있었다. 이는 이후 표준 템플릿 라이브러리와 제네릭 프로그래밍의 기반이 되었다.

예외 처리는 오류 처리 코드를 일반 실행 흐름에서 분리하는 수단을 제공했다.

class stack_underflow {
};

char Stack::pop()
{
    if (top <= bottom) {
        throw stack_underflow();
    }

    return *(--top);
}

void process(Stack& stack)
{
    try {
        char value = stack.pop();
        use(value);
    }
    catch (stack_underflow&) {
        recover();
    }
}

국제 표준화

C++ 사용자가 증가하고 여러 회사에서 서로 다른 컴파일러와 라이브러리를 개발하면서, 구현체 간 호환성을 확보하기 위한 공식 표준이 필요해졌다. 미국에서는 1989년 ANSI X3J16 위원회가 구성되었고, 국제적으로는 1990년부터 1991년 사이 ISO/IEC JTC 1/SC 22/WG 21이 조직되었다.[10]

표준화 작업은 단순히 기존 언어를 문서화하는 데 그치지 않았다. 템플릿과 예외 처리, 이름공간, 런타임 타입 정보, bool 타입과 새로운 형 변환 연산자 등이 정리되었으며, Alexander Stepanov와 Meng Lee가 개발한 표준 템플릿 라이브러리가 표준 라이브러리에 통합되었다.

C++98

1998년 최초의 국제 C++ 표준인 ISO/IEC 14882:1998이 제정되었다. C++98은 언어 문법뿐 아니라 문자열, 입출력 스트림, 컨테이너, 반복자와 알고리즘을 포함하는 표준 라이브러리를 규정했다.

C++98의 대표적인 프로그래밍 방식은 명시적인 타입과 반복자, 수동 자원 관리를 사용하는 형태였다.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    std::vector<std::string> names;

    names.push_back("Unix");
    names.push_back("C");
    names.push_back("C++");

    std::sort(names.begin(), names.end());

    for (std::vector<std::string>::const_iterator it = names.begin();
         it != names.end();
         ++it) {
        std::cout << *it << '\n';
    }

    return 0;
}

이 코드에는 std::vector, 반복자와 std::sort가 사용된다. 자료구조와 알고리즘을 분리하고 반복자로 연결하는 구조는 표준 템플릿 라이브러리의 핵심 설계였다.

C++98에서는 템플릿을 활용한 제네릭 프로그래밍이 표준적인 언어 사용 방식으로 자리 잡았다.

template<class Iterator, class T>
Iterator find_value(Iterator first, Iterator last, const T& value)
{
    while (first != last) {
        if (*first == value) {
            return first;
        }

        ++first;
    }

    return last;
}

이 함수는 배열, 연결 리스트, 벡터처럼 반복자를 제공하는 여러 자료구조에 같은 방식으로 적용할 수 있다.

C++03

2003년에 제정된 C++03은 새로운 언어 기능을 대규모로 도입한 판이라기보다 C++98의 결함 보고서와 모호한 규칙을 정리한 개정판이었다. 값 초기화와 일부 템플릿 규칙이 개선되었지만, 프로그래밍 방식 자체는 C++98과 크게 다르지 않았다.

struct Counter {
    int value;
};

// C++03에서 값 초기화 규칙이 정리되면서
// 기본 타입 멤버를 0으로 초기화하는 동작이 명확해졌다.
Counter counter = Counter();

C++03 시기에는 컴파일러와 표준 라이브러리 구현이 안정화되었으며, Boost와 같은 외부 라이브러리 생태계가 성장했다. 이후 C++11에 도입된 스마트 포인터, 정규 표현식, 스레드와 여러 메타프로그래밍 도구는 Boost에서 먼저 널리 실험된 기능의 영향을 받았다.

C++11과 현대 C++의 형성

C++11은 C++98 이후 가장 큰 변화로 평가되는 표준이다. 개발 중에는 2000년대 안에 완성될 것이라는 의미에서 C++0x라고 불렸으나, 최종 표준은 2011년에 제정되었다.[11]

C++11에는 다음과 같은 핵심 기능이 추가되었다.

  • 이동 의미론과 오른값 참조
  • auto 타입 추론
  • 람다 표현식
  • 범위 기반 for
  • nullptr
  • constexpr
  • 가변 인자 템플릿
  • 열거형 클래스
  • 위임 생성자
  • 사용자 정의 리터럴
  • 표준 스레드와 원자적 연산
  • std::unique_ptrstd::shared_ptr
  • std::tuple, std::array와 해시 기반 컨테이너

C++98 코드를 C++11 방식으로 다시 작성하면 타입과 반복자 관련 상용구가 크게 줄어든다.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    std::vector<std::string> names {
        "Unix",
        "C",
        "C++"
    };

    std::sort(names.begin(), names.end());

    for (const auto& name : names) {
        std::cout << name << '\n';
    }
}

이동 의미론은 임시 객체나 더 이상 사용하지 않는 객체의 자원을 복사하지 않고 이전할 수 있게 했다.

class Buffer {
    unsigned char* data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : data_(new unsigned char[size]),
          size_(size)
    {
    }

    ~Buffer()
    {
        delete[] data_;
    }

    // 복사 생성자는 새로운 저장 공간을 만든다.
    Buffer(const Buffer& other)
        : data_(new unsigned char[other.size_]),
          size_(other.size_)
    {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // 이동 생성자는 기존 저장 공간의 소유권만 넘긴다.
    Buffer(Buffer&& other) noexcept
        : data_(other.data_),
          size_(other.size_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }
};

스마트 포인터는 동적 객체의 수명을 객체 자체의 수명과 연결하는 RAII 방식을 표준화했다.

#include <memory>

class Device {
public:
    void start();
};

void run()
{
    std::unique_ptr<Device> device(new Device());

    device->start();

    // 함수가 끝나면 Device가 자동으로 파괴된다.
}

C++14 이후에는 std::make_unique를 사용해 같은 코드를 더 직접적으로 작성할 수 있다.

C++14

C++14는 C++11의 기능을 보완하고 더 쉽게 사용할 수 있도록 개선한 표준이다. 일반 람다, 함수 반환형 추론, 변수 템플릿, 완화된 constexpr 규칙과 std::make_unique 등이 추가되었다.

#include <iostream>
#include <memory>

auto add = [](const auto& left, const auto& right) {
    return left + right;
};

int main()
{
    auto value = std::make_unique<int>(42);

    std::cout << add(*value, 8) << '\n';
}

일반 람다는 매개변수에 auto를 사용할 수 있어, 이름이 없는 함수 객체를 템플릿처럼 사용할 수 있게 했다.

auto print_pair = [](const auto& first, const auto& second) {
    std::cout << first << ": " << second << '\n';
};

print_pair("version", 14);
print_pair(10, 20.5);

C++17

C++17은 구조적 바인딩, if constexpr, 인라인 변수, 클래스 템플릿 인자 추론과 폴드 표현식을 도입했다. 표준 라이브러리에는 std::optional, std::variant, std::any, std::string_view와 파일 시스템 라이브러리가 추가되었다.[12]

#include <iostream>
#include <map>
#include <string>

int main()
{
    std::map<std::string, int> versions {
        {"C++98", 1998},
        {"C++11", 2011},
        {"C++17", 2017}
    };

    // 구조적 바인딩으로 pair의 두 요소를 바로 분해한다.
    for (const auto& [name, year] : versions) {
        std::cout << name << ": " << year << '\n';
    }
}

if constexpr는 템플릿 인스턴스화 시점에 조건에 맞지 않는 분기를 제거할 수 있게 했다.

#include <iostream>
#include <type_traits>

template<class T>
void print_value(const T& value)
{
    if constexpr (std::is_pointer_v<T>) {
        std::cout << *value << '\n';
    }
    else {
        std::cout << value << '\n';
    }
}

C++20

C++20은 C++11 이후 가장 큰 규모의 표준으로, 개념, 범위, 모듈과 코루틴을 주요 기능으로 도입했다.[13]

개념은 템플릿 인자가 충족해야 하는 조건을 언어 문법으로 표현한다.

#include <concepts>

template<class T>
concept Number = std::integral<T> || std::floating_point<T>;

template<Number T>
T square(T value)
{
    return value * value;
}

범위 라이브러리는 반복자의 시작과 끝을 개별적으로 전달하는 대신, 컨테이너나 뷰 자체를 알고리즘에 전달할 수 있게 했다.

#include <iostream>
#include <ranges>
#include <vector>

int main()
{
    std::vector<int> values {1, 2, 3, 4, 5, 6};

    auto result = values
        | std::views::filter([](int value) {
              return value % 2 == 0;
          })
        | std::views::transform([](int value) {
              return value * value;
          });

    for (int value : result) {
        std::cout << value << '\n';
    }
}

모듈은 헤더 파일과 전처리기 중심의 소스 코드 구성 방식을 보완하기 위해 도입되었다.

export module math;

export int add(int left, int right)
{
    return left + right;
}
import math;

int main()
{
    return add(20, 22);
}

코루틴은 함수 실행을 중단하고 나중에 다시 이어서 실행할 수 있는 저수준 언어 기반을 제공했다. C++20 코루틴 자체는 완성된 비동기 프레임워크가 아니라, 라이브러리가 생성기나 작업, 비동기 입출력 타입을 구현할 수 있도록 하는 언어 기능이다.

Generator<int> count_to(int end)
{
    for (int value = 1; value <= end; ++value) {
        co_yield value;
    }
}

여기서 Generator는 표준 C++20에 직접 포함된 타입이 아니라, 코루틴 프로토콜을 이용해 별도로 구현하거나 라이브러리에서 제공해야 하는 타입이다.

C++23

C++23은 C++20의 대형 기능을 보완하면서 라이브러리의 표현력과 오류 처리 기능을 확장한 표준이다. 현재 정식 국제 표준은 ISO/IEC 14882:2024이며, 기술 작업이 2023년에 완료되었기 때문에 일반적으로 C++23이라고 부른다.[14]

C++23에는 std::expected, std::print, std::generator, std::mdspan, 범위 라이브러리 확장과 여러 constexpr 개선이 포함되었다.

std::expected는 성공 결과와 오류 값을 하나의 타입으로 표현한다.

#include <expected>
#include <string>

std::expected<int, std::string> parse_number(const std::string& text)
{
    try {
        return std::stoi(text);
    }
    catch (...) {
        return std::unexpected("숫자로 변환할 수 없다.");
    }
}

int main()
{
    auto result = parse_number("42");

    if (!result) {
        return 1;
    }

    return *result;
}

std::print는 기존 입출력 스트림보다 간결한 형식 지정 출력을 제공한다.

#include <print>
#include <string>

int main()
{
    std::string language = "C++";
    int standard = 23;

    std::println("{}{}", language, standard);
}

현대 C++에서는 직접적인 newdelete, 소유권이 불분명한 포인터와 수동 반복자 사용을 줄이고, 값 타입과 RAII, 표준 컨테이너, 스마트 포인터, 범위와 컴파일 시간 검사를 활용하는 방향이 널리 권장된다.

#include <algorithm>
#include <memory>
#include <print>
#include <ranges>
#include <string>
#include <vector>

struct Project {
    std::string name;
    int year;
};

int main()
{
    std::vector<Project> projects {
        {"C with Classes", 1979},
        {"C++", 1983},
        {"C++98", 1998},
        {"C++11", 2011},
        {"C++20", 2020},
        {"C++23", 2023}
    };

    auto modern = projects
        | std::views::filter([](const Project& project) {
              return project.year >= 2011;
          });

    for (const auto& project : modern) {
        std::println("{}: {}", project.name, project.year);
    }
}

이 코드는 C++가 초기의 클래스 확장 언어에서 값 의미론, 제네릭 프로그래밍, 자동 자원 관리와 범위 기반 데이터 처리를 결합하는 언어로 발전했음을 보여준다.

C++26과 이후의 표준화

C++23 이후에는 C++26 표준화가 진행되고 있다. WG21은 정적 리플렉션, 계약, 패턴 및 라이브러리 확장 등을 포함한 다음 표준을 개발하고 있다. 다만 표준이 정식 발행되기 전까지 세부 기능과 문법은 변경될 수 있다.[15]

C++의 표준화는 기존 프로그램과 구현체의 호환성을 유지하면서 새 추상화 기능을 추가하는 방식으로 진행된다. 이 때문에 오래된 C++ 코드가 현대 컴파일러에서도 상당 부분 유지되는 반면, 서로 다른 시대의 프로그래밍 방식이 하나의 언어 안에 공존한다.

C++의 주요 역사적 흐름
  • C with Classes
    • 1979년 Cpre 개발
    • 1980년 클래스 기반 초기 구현
    • 1981년 인라인 함수와 기본 인자
  • C++
    • 1983년 C++ 명칭과 가상 함수
    • 1985년 Cfront 1.0과 최초 상용 공개
    • 1989년 C++ 2.0
    • 1990년 ARM
  • ISO C++
    • 1998년 C++98
    • 2003년 C++03
    • 2011년 C++11
    • 2014년 C++14
    • 2017년 C++17
    • 2020년 C++20
    • 2023년 C++23 기술 작업 완료
    • C++26 표준화 진행

언어 설계와 실행 모델

C++는 특정 CPU 명령어 집합이나 운영체제, 실행 파일 형식에 직접 종속된 언어로 정의되지 않는다. C++ 표준은 프로그램이 실행되는 개념적 환경인 추상 기계와 그 안에서 발생하는 객체 생성, 표현식 평가, 함수 호출, 입출력 및 스레드 간 동작을 규정한다. 실제 컴파일러는 이 추상 기계의 동작을 대상 플랫폼의 기계어와 운영체제 기능으로 변환한다.

이 구조를 통해 같은 C++ 소스 코드를 서로 다른 프로세서와 운영체제에서 구현할 수 있다. 표준은 프로그램의 의미와 관찰 가능한 결과를 규정하지만, 레지스터 배치, 명령어 선택, 함수 호출 규약과 실행 파일 구성처럼 플랫폼에 따라 달라지는 세부 사항은 구현체에 맡긴다.

C++의 설계는 고수준 추상화를 제공하면서도 사용하지 않는 기능에 불필요한 실행 비용을 부과하지 않는 무비용 추상화 원칙과 연결된다. 이 원칙은 사용하지 않는 기능에는 시간이나 공간 비용을 지불하지 않고, 사용하는 기능은 같은 작업을 수작업으로 구현한 것보다 본질적으로 비효율적이지 않아야 한다는 방향을 뜻한다.[16]

추상 기계와 구현

C++ 표준의 추상 기계는 실제 컴퓨터를 그대로 묘사한 가상 CPU가 아니다. 프로그램의 의미를 정의하기 위해 필요한 객체, 값, 메모리 위치, 실행 스레드, 평가 순서와 입출력 동작을 설명하는 규범적 모델이다.

적합한 C++ 구현체는 올바르게 작성된 프로그램을 실행했을 때, 표준의 추상 기계가 허용하는 실행 가운데 하나와 같은 관찰 가능한 동작을 만들어야 한다.[17]

관찰 가능한 동작에는 일반적으로 다음과 같은 요소가 포함된다.

  • 프로그램이 외부 파일에 기록한 데이터
  • 표준 입출력 스트림을 통한 입출력
  • volatile 객체에 대한 접근
  • 원자적 연산과 스레드 간 동기화에 의해 드러나는 동작
  • 프로그램의 정상 종료 또는 비정상 종료

컴파일러는 관찰 가능한 결과를 유지하는 범위에서 소스 코드와 다른 방식으로 프로그램을 실행할 수 있다. 이를 흔히 as-if 규칙이라고 한다. 컴파일러는 실제로 수행할 필요가 없는 계산을 제거하거나, 여러 연산을 하나로 합치거나, 함수를 호출하지 않고 본문을 삽입할 수 있다.

다음 코드에서 temporary의 계산 결과는 외부에서 관찰되지 않는다.

int calculate()
{
    int temporary = 10 * 20;
    return 42;
}

최적화된 프로그램에서는 temporary 변수와 곱셈 연산이 완전히 제거될 수 있다. 추상 기계의 관점에서 calculate()42를 반환한다는 관찰 가능한 결과가 유지되기 때문이다.

반면 출력과 같이 외부에서 관찰할 수 있는 동작은 임의로 제거할 수 없다.

#include <iostream>

int main()
{
    std::cout << "A";
    std::cout << "B";
}

구현체는 내부 호출 구조를 변경하거나 두 출력을 하나로 결합할 수 있지만, 정상적으로 실행되는 프로그램이 최종적으로 AB를 출력한다는 의미는 유지해야 한다.

C++ 구현은 크게 호스티드 구현프리스탠딩 구현으로 구분된다. 호스티드 구현은 일반적인 운영체제 환경에서 전체 표준 라이브러리와 정해진 프로그램 시작 규칙을 제공한다. 프리스탠딩 구현은 운영체제가 없는 환경에서도 실행될 수 있으며, 임베디드 시스템, 부트로더와 운영체제 커널 같은 프로그램을 지원한다.[18]

// 프리스탠딩 환경에서는 구현체가 자체 진입점을 사용할 수 있다.
// 아래 이름과 주소는 특정 플랫폼의 예시일 뿐 C++ 표준이 정하지 않는다.

extern "C" void kernel_entry()
{
    for (;;) {
        // 하드웨어 이벤트를 처리한다.
    }
}

호스티드 환경에서는 일반적으로 main 함수가 프로그램의 시작점이지만, 프리스탠딩 환경의 시작 함수와 초기화 절차는 구현체와 대상 플랫폼이 정한다.

소스 파일과 번역 단위

C++ 프로그램은 하나 이상의 소스 파일로 구성될 수 있다. 그러나 표준에서 컴파일의 기본 단위는 파일 자체가 아니라 번역 단위다.

전처리 과정이 끝난 뒤 컴파일러가 하나의 단위로 구문과 의미를 분석하는 토큰의 집합을 번역 단위라고 한다. 헤더 파일을 #include하면 헤더가 독립적인 번역 단위로 컴파일되는 것이 아니라, 전처리 과정에서 포함한 소스 파일 안으로 들어간 것처럼 처리된다.

예를 들어 다음과 같은 헤더가 있다고 가정할 수 있다.

// calculator.hpp

#pragma once

int add(int left, int right);

구현은 별도의 소스 파일에 작성할 수 있다.

// calculator.cpp

#include "calculator.hpp"

int add(int left, int right)
{
    return left + right;
}

이를 사용하는 소스 파일은 또 다른 번역 단위를 이룬다.

// main.cpp

#include "calculator.hpp"
#include <iostream>

int main()
{
    std::cout << add(20, 22) << '\n';
}

일반적인 빌드에서는 calculator.cppmain.cpp가 각각 독립적으로 번역된다.

여러 소스 파일의 일반적인 빌드 흐름
  1. calculator.cpp와 포함된 헤더
  2. 첫 번째 번역 단위 분석 및 목적 파일 생성
  3. main.cpp와 포함된 헤더
  4. 두 번째 번역 단위 분석 및 목적 파일 생성
  5. 목적 파일과 라이브러리 결합
  6. 실행 파일 또는 공유 라이브러리 생성

각 번역 단위는 다른 번역 단위의 소스 내용을 직접 알지 못한다. 대신 외부 연결을 가진 함수나 변수의 이름을 통해 서로 결합한다. 이후 링커가 각 번역 단위에서 생성된 목적 코드와 필요한 라이브러리를 결합한다.

C++20의 모듈도 번역 단위를 없애지 않는다. 모듈 인터페이스 단위, 모듈 구현 단위와 헤더 단위 역시 각각 번역 단위의 한 종류다.

// math.cppm

export module math;

export int multiply(int left, int right)
{
    return left * right;
}
// main.cpp

import math;

int main()
{
    return multiply(6, 7);
}

모듈은 공개할 이름을 명시적으로 관리하고, 전처리기 기반의 텍스트 포함에 의존하지 않고 번역된 선언을 가져올 수 있게 한다. 그러나 구체적인 모듈 파일 형식과 빌드 명령은 컴파일러와 빌드 시스템에 따라 달라진다.

번역 과정

C++ 표준은 소스 코드가 프로그램으로 해석되는 과정을 여러 번역 단계로 설명한다. 실제 컴파일러가 반드시 각 단계를 별도의 프로그램이나 순차 처리 과정으로 구현해야 하는 것은 아니지만, 결과는 표준이 규정한 단계의 순서와 같은 의미를 가져야 한다.[19]

주요 흐름은 다음과 같이 정리할 수 있다.

C++ 번역의 개념적 흐름
  1. 소스 파일의 문자 해석
  2. 줄 연결과 주석 처리
  3. 전처리 토큰 분석
  4. 전처리 지시문 실행과 헤더 포함
  5. 문자 및 문자열 리터럴 처리
  6. 인접 문자열 리터럴 결합
  7. 번역 단위의 구문 및 의미 분석
  8. 필요한 템플릿 인스턴스화
  9. 번역 단위와 라이브러리 결합

전처리기는 C++의 일반적인 타입 체계와 이름 공간을 이해하지 않는다. 매크로는 토큰 수준에서 처리되므로 일반 함수와 다른 의미를 가진다.

#define SQUARE(value) value * value

int result = SQUARE(1 + 2);

전처리 결과는 개념적으로 다음과 같다.

int result = 1 + 2 * 1 + 2;

연산자 우선순위 때문에 결과는 9가 아니라 5가 된다. 같은 기능을 함수나 constexpr 함수로 표현하면 C++의 타입 검사와 표현식 규칙을 적용할 수 있다.

constexpr int square(int value)
{
    return value * value;
}

static_assert(square(1 + 2) == 9);

템플릿은 전처리기 매크로와 달리 번역 단위의 구문 및 의미 분석 과정에 참여한다.

template<class T>
T maximum(T left, T right)
{
    return left < right ? right : left;
}

int integer = maximum(10, 20);
double real = maximum(1.5, 3.0);

컴파일러는 사용된 템플릿 인자에 따라 필요한 특수화를 생성하고 각 타입에 대해 표현식이 올바른지 검사한다.

선언과 정의

선언은 이름이나 개체를 프로그램에 도입하고, 그 이름의 타입과 의미를 컴파일러에 알린다. 정의는 선언의 한 종류이며, 함수 본문을 제공하거나 객체에 필요한 저장 공간을 마련하는 등 해당 개체를 실제로 구성한다.[20]

다음 문장은 함수의 선언이다.

int calculate(int value);

함수 본문이 없으므로 이 선언만으로는 함수의 동작이 제공되지 않는다.

다음 문장은 같은 함수의 정의다.

int calculate(int value)
{
    return value * 2;
}

변수에서도 선언과 정의를 구분할 수 있다.

// 다른 번역 단위에 정의된 변수를 선언한다.
extern int global_count;
// 저장 공간을 갖는 변수를 정의한다.
int global_count = 0;

일반적으로 하나의 프로그램에서 odr-use되는 비인라인 함수와 변수는 하나의 정의만 가져야 한다. 이 규칙을 단일 정의 규칙, 즉 ODR이라고 한다.

다음과 같이 정의를 헤더 파일에 직접 작성하면 그 헤더를 포함한 여러 번역 단위에 같은 함수 정의가 생성될 수 있다.

// 잘못 구성된 header.hpp

int next_value()
{
    static int value = 0;
    return ++value;
}

함수에 inline을 지정하면 외부 또는 모듈 연결을 가진 동일한 정의를 여러 번역 단위에 둘 수 있다. 이 경우에도 프로그램 전체에서는 하나의 개체로 취급된다.[21]

inline int next_value()
{
    static int value = 0;
    return ++value;
}

클래스 정의 내부에 작성한 멤버 함수와 함수 템플릿은 여러 번역 단위에서 사용할 수 있도록 별도의 관련 규칙을 적용받는다.

객체와 값

C++에서 객체는 특정 타입을 가지며, 데이터가 저장되는 메모리 영역을 차지할 수 있는 실행 중 개체다. 객체에는 저장 기간과 수명이 있으며, 다른 객체를 하위 객체로 포함할 수도 있다.[22]

다음 코드에는 여러 객체가 존재한다.

struct Position {
    int x;
    int y;
};

Position player {10, 20};

playerPosition 타입의 완전한 객체다. 그 안의 xy는 각각 int 타입의 멤버 하위 객체다.

배열도 객체이며 각 원소는 배열의 하위 객체다.

int values[3] {10, 20, 30};

values는 배열 객체이고, values[0], values[1], values[2]는 각각 배열 원소 객체다.

C++의 변수와 객체는 같은 개념이 아니다. 변수는 선언으로 도입된 이름 있는 객체 또는 참조를 뜻하지만, 모든 객체가 변수인 것은 아니다. 임시 객체, 동적으로 생성한 이름 없는 객체와 다른 객체 내부의 하위 객체도 객체다.

#include <string>

void print(std::string text);

int main()
{
    // std::string 임시 객체가 생성된다.
    print(std::string("C++"));
}

값은 표현식을 평가한 결과로 사용된다. 같은 값이라도 서로 다른 객체에 저장될 수 있다.

int first = 42;
int second = 42;

firstsecond는 서로 다른 객체지만 같은 값 42를 가진다.

C++ 객체의 메모리 표현은 타입, 정렬 요구 사항, 구현체와 대상 ABI에 영향을 받는다. 구조체 멤버 사이에는 정렬을 맞추기 위한 패딩이 삽입될 수 있다.

struct Record {
    char tag;
    int value;
};

이 구조체의 크기가 반드시 sizeof(char) + sizeof(int)와 같지는 않다. 구현체는 value를 적절한 주소에 배치하기 위해 tag 뒤에 패딩을 둘 수 있다.

#include <cstddef>

static_assert(sizeof(Record) >= sizeof(char) + sizeof(int));

표준 레이아웃 타입과 trivially copyable 타입에는 메모리 배치와 바이트 단위 복사에 관한 추가 보장이 존재하지만, 모든 클래스가 같은 보장을 받는 것은 아니다.

저장 기간과 객체 수명

저장 기간은 객체를 담는 저장 공간이 잠재적으로 얼마나 오래 유지되는지를 나타낸다. C++은 정적, 스레드, 자동, 동적 저장 기간을 규정한다.[23]

저장 기간일반적인 생성 방식저장 공간이 유지되는 범위
정적 저장 기간전역 변수, 이름공간 변수, 정적 지역 변수프로그램 실행 기간
스레드 저장 기간thread_local 변수해당 스레드의 실행 기간
자동 저장 기간일반적인 지역 변수와 함수 매개변수선언된 블록 또는 함수 호출
동적 저장 기간new 표현식과 동적 할당명시적으로 해제되거나 소유 객체가 해제할 때까지

다음 코드에는 여러 저장 기간의 객체가 함께 존재한다.

#include <memory>

int global_count = 0;             // 정적 저장 기간
thread_local int thread_count = 0; // 스레드 저장 기간

void process()
{
    int local_count = 0;          // 자동 저장 기간

    auto dynamic_count =
        std::make_unique<int>(0); // 가리키는 int 객체는 동적 저장 기간
}

저장 기간과 객체 수명은 관련되어 있지만 같은 개념은 아니다. 저장 공간이 존재한다고 해서 그 안에 항상 살아 있는 객체가 존재하는 것은 아니다. 객체의 수명은 초기화가 완료되는 등 타입에 맞는 조건이 충족될 때 시작하고, 소멸자가 시작되거나 저장 공간이 재사용 또는 해제될 때 끝난다.[24]

다음 코드에서 resource의 수명은 함수 블록에 진입해 생성자가 완료된 뒤 시작하고, 블록을 벗어날 때 소멸자가 호출되면서 끝난다.

#include <iostream>

class Resource {
public:
    Resource()
    {
        std::cout << "획득\n";
    }

    ~Resource()
    {
        std::cout << "반환\n";
    }
};

void use_resource()
{
    Resource resource;

    std::cout << "사용\n";
}

실행 흐름은 다음과 같다.

자동 객체의 일반적인 수명
  1. 객체를 위한 저장 공간 확보
  2. 생성자 실행
  3. 객체 수명 시작
  4. 객체 사용
  5. 소멸자 실행
  6. 객체 수명 종료
  7. 저장 공간 해제 또는 재사용

C++에서는 저장 공간을 객체의 수명과 별도로 직접 다룰 수도 있다. 배치 new는 이미 존재하는 저장 공간에 객체를 생성한다.

#include <cstddef>
#include <new>

struct Packet {
    int type;
    int size;
};

int main()
{
    alignas(Packet) std::byte storage[sizeof(Packet)];

    Packet* packet = new (storage) Packet {1, 128};

    int size = packet->size;

    packet->~Packet();

    return size;
}

storage 배열의 수명과 그 안에 생성된 Packet 객체의 수명은 서로 다르다. Packet의 소멸자를 호출한 뒤에도 바이트 배열의 저장 공간은 남아 있지만, 그 공간 안의 Packet 객체는 더 이상 살아 있지 않다.

일반적인 애플리케이션 코드에서는 이런 저수준 수명 조작보다 컨테이너와 스마트 포인터를 사용하는 편이 객체 수명을 명확하게 관리하기 쉽다.

#include <memory>

struct Device {
    void start();
    void stop();
};

void run()
{
    auto device = std::make_unique<Device>();

    device->start();

    // 함수가 끝나면 unique_ptr가 Device를 파괴한다.
}

값 범주와 참조

C++의 표현식은 단순히 값을 계산하는 것만이 아니라, 객체를 식별하거나 이동 가능한 자원을 나타낼 수도 있다. 이 차이를 표현하기 위해 각 표현식에는 값 범주가 부여된다.

모든 표현식은 최종적으로 다음 세 기본 범주 가운데 하나에 속한다.

  • lvalue: 지속적인 정체성을 가진 객체나 함수를 나타내는 표현식
  • xvalue: 정체성을 가지면서 자원을 재사용할 수 있는 객체를 나타내는 표현식
  • prvalue: 값을 계산하거나 객체를 초기화하기 위한 순수 값을 나타내는 표현식

표준은 lvalue와 xvalue를 합쳐 glvalue, prvalue와 xvalue를 합쳐 rvalue라고 분류한다.[25]

C++ 값 범주의 관계
  • 표현식
    • glvalue
      • lvalue
      • xvalue
    • rvalue
      • prvalue
      • xvalue

다음 코드에서 이름이 있는 변수 표현식 value는 lvalue다.

int value = 42;

int* pointer = &value;
value = 100;

정수 리터럴 42와 덧셈 결과는 prvalue다.

int first = 20;
int second = 22;

int result = first + second;

std::move는 객체 자체를 이동하지 않는다. 전달받은 표현식을 이동 가능한 xvalue로 변환한다.

#include <string>
#include <utility>

std::string source = "C++";
std::string destination = std::move(source);

std::move(source)는 xvalue 표현식이다. 이를 이용해 destination의 이동 생성자가 source가 가진 내부 자원을 넘겨받을 수 있다. 이동 이후에도 source 객체의 수명은 계속되며, 유효하지만 구체적인 내용이 보장되지 않는 상태가 될 수 있다.

참조는 다른 객체나 함수에 대한 별칭을 제공한다.

int value = 10;

int& reference = value;
reference = 20;

reference를 통해 값을 변경하면 실제 객체인 value가 변경된다.

오른값 참조는 일반적으로 임시 객체나 이동 가능한 객체를 받아 자원 이전을 구현할 때 사용한다.

#include <string>
#include <utility>

void consume(std::string&& text)
{
    std::string owned = std::move(text);
}

int main()
{
    consume(std::string("temporary"));
}

참조 자체에도 수명이 있지만, 참조가 다른 객체의 수명을 항상 연장하는 것은 아니다. 특정한 초기화 문맥에서는 임시 객체의 수명이 참조에 맞춰 연장되지만, 함수 매개변수나 반환 과정에서는 같은 규칙이 그대로 적용되지 않을 수 있다.

#include <string>

const std::string& text = std::string("C++");

// 이 초기화에서는 임시 std::string의 수명이
// text의 수명에 맞춰 연장된다.

반면 지역 객체를 참조로 반환하면 함수 종료와 함께 객체의 수명이 끝난다.

#include <string>

const std::string& invalid_text()
{
    std::string text = "C++";

    // text는 함수가 끝날 때 파괴된다.
    return text;
}

반환된 참조를 사용하면 이미 수명이 끝난 객체에 접근하게 된다.

복사와 이동

C++의 객체는 타입이 제공하는 복사 생성자, 복사 대입 연산자, 이동 생성자와 이동 대입 연산자를 통해 상태를 전달할 수 있다.

복사는 원본과 독립적인 새 객체를 만든다.

#include <string>

std::string original = "C++";
std::string copied = original;

copied += "23";

originalcopied는 서로 다른 객체이며, 한쪽을 수정해도 다른 쪽의 문자열 값은 바뀌지 않는다.

이동은 원본 객체가 가진 자원을 새 객체가 재사용할 수 있게 한다.

#include <string>
#include <utility>
#include <vector>

std::vector<std::string> create_names()
{
    std::vector<std::string> names;

    names.push_back("C");
    names.push_back("C++");

    return names;
}

반환 시 구현체는 복사를 수행하는 대신 객체를 직접 호출자의 결과 저장 공간에 생성하거나 이동을 사용할 수 있다. C++17 이후 일부 상황에서는 이러한 복사 생략이 선택적 최적화가 아니라 언어 의미 자체로 보장된다.

직접 자원을 관리하는 클래스는 복사와 이동의 의미를 명확히 정의해야 한다.

#include <algorithm>
#include <cstddef>
#include <utility>

class Buffer {
    std::size_t size_ = 0;
    unsigned char* data_ = nullptr;

public:
    explicit Buffer(std::size_t size)
        : size_(size),
          data_(new unsigned char[size])
    {
    }

    ~Buffer()
    {
        delete[] data_;
    }

    Buffer(const Buffer& other)
        : size_(other.size_),
          data_(new unsigned char[other.size_])
    {
        std::copy(
            other.data_,
            other.data_ + other.size_,
            data_
        );
    }

    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)),
          data_(std::exchange(other.data_, nullptr))
    {
    }
};

복사 생성자는 새로운 저장 공간을 할당하고 데이터를 복제한다. 이동 생성자는 기존 포인터를 넘겨받고 원본 객체가 더 이상 그 저장 공간을 소유하지 않도록 만든다.

실제 프로그램에서는 직접 배열을 소유하기보다 std::vector, std::string과 스마트 포인터처럼 복사와 이동 규칙이 이미 구현된 타입을 조합하는 방식이 일반적이다.

#include <string>
#include <vector>

class Document {
    std::string title_;
    std::vector<std::string> lines_;

public:
    Document(
        std::string title,
        std::vector<std::string> lines
    )
        : title_(std::move(title)),
          lines_(std::move(lines))
    {
    }
};

이 클래스는 소멸자와 복사·이동 함수를 직접 작성하지 않아도 각 멤버가 자신의 자원을 관리한다. 이를 0의 규칙이라고 부른다.

실행 순서와 표현식 평가

C++ 프로그램의 각 표현식은 하나 이상의 값 계산과 부수 효과를 발생시킬 수 있다. 값 계산은 객체의 값을 읽거나 결과를 계산하는 작업이고, 부수 효과는 객체 수정, 입출력, 함수 호출과 같은 상태 변화를 포함한다.

int value = 10;

int result = value++;

후위 증가 표현식은 증가 전의 값을 결과로 계산하고, value를 증가시키는 부수 효과를 발생시킨다.

C++은 모든 하위 표현식의 평가 순서를 항상 왼쪽에서 오른쪽으로 규정하지 않는다. 연산자와 문맥에 따라 평가 순서가 지정되거나, 순서만 불특정하거나, 서로 순서가 정해지지 않을 수 있다.

다음 코드에서 함수 인자들은 모두 함수 본문에 들어가기 전에 평가되지만, first()second() 가운데 어느 쪽이 먼저 호출되는지는 일반적으로 고정되지 않는다.

int first();
int second();
void use(int left, int right);

int main()
{
    use(first(), second());
}

호출 순서에 의존해야 한다면 표현식을 분리해 순서를 명시할 수 있다.

int left = first();
int right = second();

use(left, right);

논리 AND와 OR, 조건 연산자, 쉼표 연산자 등 일부 연산자는 특정한 평가 순서를 보장한다.

bool pointer_is_valid(const int* pointer)
{
    return pointer != nullptr && *pointer > 0;
}

&&의 왼쪽 피연산자가 false이면 오른쪽 피연산자는 평가되지 않으므로, 널 포인터를 역참조하지 않는다.

컴파일 시간과 실행 시간

C++은 일부 계산을 프로그램 실행 전에 번역 과정에서 수행할 수 있다. 상수 표현식은 배열 크기, 템플릿 인자, 열거형 값과 static_assert 같은 문맥에서 사용된다.

constexpr int square(int value)
{
    return value * value;
}

constexpr int area = square(12);

static_assert(area == 144);

constexpr 함수는 상수 표현식 문맥뿐 아니라 실행 시간에도 호출할 수 있다.

int read_value();

int main()
{
    int value = read_value();

    // 입력값은 번역 시 알 수 없으므로 실행 시간에 계산된다.
    int result = square(value);

    return result;
}

같은 square 함수라도 인자가 상수 표현식이면 컴파일 시간에 평가될 수 있고, 실행 중에 얻은 값이면 일반 함수처럼 실행된다.

consteval 함수는 반드시 상수 평가 과정에서 호출되어야 한다.

consteval unsigned long long hash_literal(const char* text)
{
    unsigned long long hash = 1469598103934665603ULL;

    while (*text != '\0') {
        hash ^= static_cast<unsigned char>(*text++);
        hash *= 1099511628211ULL;
    }

    return hash;
}

constexpr auto identifier = hash_literal("renderer");

이러한 기능은 타입 구성, 테이블 생성, 문자열 처리와 검증 작업을 실행 시간에서 번역 시간으로 옮길 수 있게 한다. 다만 컴파일 시간 계산도 추상 기계의 규칙에 따라 평가되며, 상수 표현식에서 허용되는 연산에는 별도의 제한이 적용된다.

정의된 동작과 구현 의존성

C++ 표준은 모든 상황에서 하나의 동일한 결과만을 요구하지 않는다. 프로그램의 동작은 표준이 얼마나 구체적으로 규정하는지에 따라 여러 범주로 나뉜다.

분류의미
정의된 동작표준이 프로그램의 의미를 규정한 동작
구현 정의 동작구현체가 선택하고 그 선택을 문서화해야 하는 동작
불특정 동작허용된 여러 동작 중 하나가 선택되지만 선택을 문서화할 의무가 없는 동작
미정의 동작표준이 아무 요구 사항도 부과하지 않는 동작

구현 정의 동작의 대표적인 예로 기본 정수 타입의 크기와 일반 char가 부호 있는 타입인지 여부 등이 있다.

#include <climits>
#include <iostream>

int main()
{
    std::cout << sizeof(int) << '\n';

#if CHAR_MIN < 0
    std::cout << "char is signed\n";
#else
    std::cout << "char is unsigned\n";
#endif
}

구현체는 이런 선택을 문서화해야 하며, 프로그램은 <limits><climits>와 같은 표준 기능을 통해 현재 구현의 특성을 확인할 수 있다.

불특정 동작은 가능한 결과의 범위는 표준이 허용하지만, 구현체가 하나의 선택을 문서화할 필요는 없는 경우다. 함수 인자의 평가 순서처럼 프로그램이 특정 순서에 의존하지 않아야 하는 상황이 이에 포함될 수 있다.

미정의 동작은 표준이 결과에 어떤 요구도 하지 않는 동작이다.[26]

다음 코드는 배열 범위를 벗어난다.

int values[3] {10, 20, 30};

int result = values[3];

유효한 인덱스는 0부터 2까지이므로 values[3] 접근의 동작은 정의되지 않는다.

수명이 끝난 객체에 접근하는 것도 미정의 동작이 될 수 있다.

int* create_pointer()
{
    int value = 42;
    return &value;
}

int main()
{
    int* pointer = create_pointer();

    return *pointer;
}

부호 있는 정수의 범위를 넘는 산술 오버플로도 미정의 동작이다.

#include <limits>

int value = std::numeric_limits<int>::max();
int result = value + 1;

반면 부호 없는 정수 산술은 타입의 표현 가능한 값 범위를 기준으로 모듈러 연산을 수행한다.

#include <limits>

unsigned int value =
    std::numeric_limits<unsigned int>::max();

unsigned int result = value + 1;

// result는 0이다.

컴파일러는 올바르게 정의된 프로그램을 번역한다고 가정해 최적화를 수행할 수 있다. 따라서 미정의 동작이 포함된 코드는 단순히 예측할 수 없는 값 하나를 반환하는 데 그치지 않고, 주변 코드의 제거와 재배치에도 영향을 줄 수 있다.

int divide(int value)
{
    int result = 100 / value;

    if (value == 0) {
        return 0;
    }

    return result;
}

나눗셈이 먼저 실행되는 경로에서 value0이면 이미 미정의 동작이 발생한다. 컴파일러는 정의된 실행에서는 value0일 수 없다고 추론할 수 있으므로 이후 검사를 의미 없는 코드로 판단할 수 있다.

검사는 동작이 발생하기 전에 수행해야 한다.

int divide(int value)
{
    if (value == 0) {
        return 0;
    }

    return 100 / value;
}

메모리 모델과 스레드

C++11부터 표준은 여러 실행 스레드가 하나의 프로그램 안에서 메모리를 공유할 때의 동작을 공식적으로 규정한다. 각 스레드의 평가 순서뿐 아니라 원자적 연산, 동기화와 happens-before 관계를 통해 한 스레드의 쓰기가 다른 스레드에서 언제 관찰되는지를 정의한다.

동기화 없이 여러 스레드가 같은 일반 객체에 접근하고, 그중 하나 이상이 쓰기 작업을 수행하면 데이터 경쟁이 발생할 수 있다. 데이터 경쟁이 발생한 프로그램의 동작은 정의되지 않는다.

#include <thread>

int counter = 0;

void increment()
{
    for (int index = 0; index < 1000; ++index) {
        ++counter;
    }
}

int main()
{
    std::thread first(increment);
    std::thread second(increment);

    first.join();
    second.join();
}

두 스레드가 counter를 동시에 읽고 수정하므로 이 프로그램에는 데이터 경쟁이 존재한다.

원자적 객체를 사용하면 하나의 객체에 대한 기본 연산을 스레드 간에 안전하게 수행할 수 있다.

#include <atomic>
#include <thread>

std::atomic<int> counter = 0;

void increment()
{
    for (int index = 0; index < 1000; ++index) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main()
{
    std::thread first(increment);
    std::thread second(increment);

    first.join();
    second.join();

    return counter.load();
}

memory_order_relaxedcounter 자체의 원자성은 보장하지만, 다른 메모리 접근 사이의 추가적인 순서 관계는 제공하지 않는다. 여러 객체의 상태를 하나의 불변 조건으로 보호해야 한다면 뮤텍스와 같은 상위 동기화 수단을 사용할 수 있다.

#include <mutex>
#include <string>

std::mutex state_mutex;
std::string state;

void update_state(std::string next)
{
    std::lock_guard<std::mutex> lock(state_mutex);

    state = std::move(next);
}

std::lock_guard는 생성될 때 뮤텍스를 잠그고, 블록을 벗어날 때 소멸되면서 잠금을 해제한다. 이는 객체 수명과 동기화 자원 관리를 결합한 RAII의 예다.

실행 모델과 최적화의 관계

C++ 컴파일러는 타입 정보, 객체 수명, 별칭 규칙과 미정의 동작 규정을 이용해 프로그램을 최적화한다. 따라서 추상 기계의 규칙은 문법적 설명에 그치지 않고 실제 생성 코드에 직접 영향을 준다.

다음 함수는 일반적인 최적화에서 상수 하나를 반환하는 코드로 축약될 수 있다.

int calculate()
{
    int left = 20;
    int right = 22;

    return left + right;
}

함수 호출도 호출 자체의 의미를 유지할 필요가 없다면 인라인될 수 있다.

int square(int value)
{
    return value * value;
}

int calculate()
{
    return square(12);
}

최적화된 결과에서는 함수 호출과 곱셈이 모두 제거되고 144가 직접 사용될 수 있다.

가상 함수 호출은 일반적으로 실행 시간 동적 디스패치를 사용하지만, 컴파일러가 실제 객체 타입을 확정할 수 있다면 호출 대상을 정적으로 결정할 수도 있다.

class Shape {
public:
    virtual int area() const = 0;
    virtual ~Shape() = default;
};

class Square final : public Shape {
    int side_;

public:
    explicit Square(int side)
        : side_(side)
    {
    }

    int area() const override
    {
        return side_ * side_;
    }
};

int calculate()
{
    Square square(10);

    return square.area();
}

Squarefinal이고 실제 객체 타입이 명확하므로, 구현체는 가상 호출을 직접 호출로 바꾸고 결과를 추가로 최적화할 수 있다.

C++에서 추상화와 최적화는 서로 반대되는 개념이 아니다. 클래스, 템플릿, 람다와 범위 같은 고수준 구조도 컴파일러가 구체적인 타입과 실행 경로를 분석할 수 있다면 저수준 코드와 유사한 형태로 변환될 수 있다. 다만 객체 수명, 별칭, 평가 순서와 동시성 규칙을 위반한 프로그램은 이러한 최적화 가정과 충돌할 수 있다.

타입 체계

C++는 모든 객체, 참조, 함수와 표현식에 컴파일 시점의 타입을 부여하는 정적 타입 언어다. 타입은 값이 어떤 형태로 표현되는지, 객체가 어떤 연산을 지원하는지, 함수가 어떤 인자를 받고 무엇을 반환하는지, 그리고 서로 다른 표현식 사이에 어떤 변환이 허용되는지를 결정한다.

C++ 표준은 타입을 크게 기본 타입복합 타입으로 구분한다. 기본 타입은 언어가 직접 제공하는 산술 타입, voidstd::nullptr_t 등으로 이루어지며, 복합 타입은 배열, 함수, 포인터, 참조, 클래스, 공용체, 열거형과 멤버 포인터처럼 다른 타입이나 선언을 바탕으로 구성된다.[27]

C++의 타입 체계는 단순히 변수에 붙는 이름 목록이 아니다. 다음 요소가 함께 작동한다.

  • 객체와 표현식의 정적 타입
  • 클래스 상속과 가상 함수에 의한 동적 타입
  • 정수·부동소수점 타입의 표현 범위
  • 포인터와 참조가 가리키는 대상 타입
  • constvolatile 한정
  • 함수 오버로딩과 템플릿 인자 추론
  • 암시적·명시적 타입 변환
  • 개념과 requires를 통한 타입 제약
  • 타입 특성과 컴파일 시간 타입 검사
#include <string>
#include <vector>

int count = 10;
double ratio = 0.5;
std::string name = "C++";
std::vector<int> values {1, 2, 3};

이 코드에서 각 객체의 타입은 컴파일 시점에 결정된다. count에는 int, ratio에는 double, name에는 std::string, values에는 std::vector<int> 타입이 부여된다. 실행 중에 count 객체의 타입이 double이나 문자열로 바뀌는 일은 없다.

정적 타입과 동적 타입

표현식의 정적 타입은 소스 코드를 번역할 때 결정되는 타입이다. 일반적인 객체에서는 정적 타입이 곧 그 객체의 타입이다.

int value = 42;
double result = value * 0.5;

value 표현식의 정적 타입은 int이며, 곱셈 과정에서는 valuedouble로 변환되어 계산된다. 그러나 원래 객체의 타입이 변경되는 것은 아니다.

다형적 클래스의 포인터나 참조를 통해 객체를 다룰 때에는 동적 타입도 구분된다. 동적 타입은 실행 중 해당 표현식이 실제로 가리키는 가장 파생된 객체의 타입이다.

class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Circle final : public Shape {
    double radius_;

public:
    explicit Circle(double radius)
        : radius_(radius)
    {
    }

    double area() const override
    {
        return 3.141592653589793 * radius_ * radius_;
    }
};

Circle circle(10.0);
Shape& shape = circle;

shape 표현식의 정적 타입은 Shape지만, 참조하는 객체의 동적 타입은 Circle이다. 따라서 shape.area()는 가상 함수 디스패치를 통해 Circle::area()를 호출한다.

double result = shape.area();

동적 타입은 모든 클래스에 존재하는 별도의 런타임 태그를 뜻하지 않는다. 다형적 클래스, 즉 하나 이상의 가상 함수를 가진 클래스에 대해 가상 함수 호출이나 dynamic_cast, typeid가 필요한 상황에서 중요한 개념이다.

기본 타입

기본 타입은 다른 타입으로부터 구성되지 않고 언어가 직접 제공하는 타입이다. 여기에는 void, std::nullptr_t, 정수 타입과 부동소수점 타입이 포함된다.[28]

C++ 기본 타입
  • 기본 타입
    • void
    • std::nullptr_t
    • 정수 타입
      • 불리언 타입
      • 문자 타입
      • 표준 부호 있는 정수 타입
      • 표준 부호 없는 정수 타입
      • 확장 정수 타입
    • 부동소수점 타입
      • float
      • double
      • long double
      • 구현체가 제공하는 확장 부동소수점 타입

void

void는 값의 집합이 없는 불완전 타입이다. 일반 객체나 참조의 타입으로 사용할 수 없지만, 값을 반환하지 않는 함수의 반환형과 타입이 지정되지 않은 객체를 가리키는 포인터에 사용된다.

void print_message()
{
}

void* raw_pointer = nullptr;

void 객체는 만들 수 없다.

void object;     // 오류
void& reference; // 오류

void*는 객체 타입을 가리키는 포인터를 저장할 수 있지만, 가리키는 타입에 관한 정보가 없으므로 직접 역참조할 수 없다.

int value = 42;

void* raw = &value;

// int result = *raw; // 오류: void 객체를 역참조할 수 없다.

int* typed = static_cast<int*>(raw);
int result = *typed;

원래 포인터가 가리키던 타입과 일치하지 않는 타입으로 변환해 접근하면 객체 표현과 별칭 규칙을 위반할 수 있다.

std::nullptr_t

nullptr 리터럴의 타입은 std::nullptr_t다. 이 타입의 값은 모든 객체 포인터와 함수 포인터 타입의 널 포인터 값으로 변환될 수 있다.

#include <cstddef>

std::nullptr_t empty = nullptr;

int* integer_pointer = empty;
void (*function_pointer)() = empty;

nullptr는 정수 0을 널 포인터 상수로 사용하던 기존 방식에서 발생하던 오버로드 모호성을 줄인다.

void open(int descriptor);
void open(const char* path);

open(0);       // int 오버로드
open(nullptr); // const char* 오버로드

불리언 타입

bool은 논리적 참과 거짓을 표현하는 정수 타입이다. 가능한 값은 truefalse다.

bool running = true;
bool finished = false;

정수와 포인터는 조건 문맥에서 bool로 변환될 수 있다.

int count = 3;
int* pointer = &count;

if (count) {
    // count가 0이 아니므로 실행된다.
}

if (pointer) {
    // pointer가 널 포인터가 아니므로 실행된다.
}

bool을 정수 타입으로 변환하면 false0, true1이 된다.

문자 타입

C++는 서로 구별되는 여러 문자 타입을 제공한다.

타입주된 용도
char기본 문자 및 바이트 단위 데이터
signed char명시적인 부호 있는 좁은 정수
unsigned char명시적인 부호 없는 좁은 정수와 객체 표현 접근
wchar_t구현체가 정의하는 확장 문자
char8_tUTF-8 코드 단위
char16_tUTF-16 코드 단위
char32_tUTF-32 코드 단위

char, signed char, unsigned char는 서로 별개의 타입이다. char가 실제로 부호 있는 범위를 사용하는지 부호 없는 범위를 사용하는지는 구현 정의다.[29]

char character = 'A';
signed char signed_byte = -1;
unsigned char byte = 255;

문자 리터럴의 접두사에 따라 타입이 달라진다.

char ordinary = 'A';
wchar_t wide = L'A';
char8_t utf8 = u8'A';
char16_t utf16 = u'A';
char32_t utf32 = U'A';

문자열 리터럴도 문자 타입에 대응하는 배열 타입을 가진다.

const char ordinary[] = "C++";
const wchar_t wide[] = L"C++";
const char8_t utf8[] = u8"C++";
const char16_t utf16[] = u"C++";
const char32_t utf32[] = U"C++";

char8_t, char16_t, char32_t가 유니코드 문자열 전체를 하나의 문자 단위로 직접 표현한다는 뜻은 아니다. 이들은 각각 인코딩의 코드 단위를 저장한다. 하나의 사용자 인식 문자가 여러 코드 단위로 구성될 수 있다.

표준 정수 타입

표준 부호 있는 정수 타입은 다음 순서로 최소 표현 범위가 커진다.

signed char
short
int
long
long long

각 부호 있는 정수 타입에는 대응하는 부호 없는 타입이 있다.

unsigned char
unsigned short
unsigned int
unsigned long
unsigned long long

다음 선언은 일부 생략형을 포함한다.

signed int first = -10;
unsigned int second = 10;

short third = -20;
unsigned short fourth = 20;

long fifth = -30;
unsigned long sixth = 30;

long long seventh = -40;
unsigned long long eighth = 40;

int의 정확한 비트 수는 표준이 하나로 고정하지 않는다. 대신 각 타입의 최소 표현 범위와 타입 간 크기 순서를 규정한다.

static_assert(sizeof(short) <= sizeof(int));
static_assert(sizeof(int) <= sizeof(long));
static_assert(sizeof(long) <= sizeof(long long));

정확한 폭이 필요한 프로그램에서는 <cstdint>의 선택적 고정 폭 정수 타입을 사용할 수 있다.

#include <cstdint>

std::int8_t small = 10;
std::uint16_t port = 8080;
std::int32_t count = 1'000'000;
std::uint64_t identifier = 0xFFFF'FFFF'FFFF'FFFFULL;

std::int32_t 같은 정확한 폭의 타입은 구현체가 패딩 없이 정확히 해당 폭을 가진 타입을 제공할 수 있을 때 정의된다. 모든 구현에서 반드시 존재하는 것은 아니다.

폭이 최소한 특정 크기 이상이면 되는 경우에는 std::int_least32_tstd::int_fast32_t를 사용할 수 있다.

#include <cstdint>

std::int_least32_t stored_value = 0;
std::int_fast32_t calculation_value = 0;

부호 있는 정수와 부호 없는 정수

부호 없는 정수 연산은 표현 가능한 값의 개수를 법으로 하는 모듈러 산술을 따른다.

#include <limits>

unsigned int value =
    std::numeric_limits<unsigned int>::max();

value += 1;

// value는 0이 된다.

부호 있는 정수 연산이 표현 가능한 범위를 벗어나면 동작이 정의되지 않는다.

#include <limits>

int value = std::numeric_limits<int>::max();

// int result = value + 1;
// 부호 있는 정수 오버플로로 동작이 정의되지 않는다.

부호 없는 타입은 비트 마스크, 해시, 모듈러 산술과 객체 크기처럼 음수가 의미 없는 값을 표현할 때 사용된다. 그러나 단순히 음수를 금지하기 위해 모든 정수에 부호 없는 타입을 사용하면 비교와 뺄셈에서 예상하지 못한 변환이 발생할 수 있다.

int signed_value = -1;
unsigned int unsigned_value = 1;

bool result = signed_value < unsigned_value;

일반적인 산술 변환 과정에서 signed_value가 부호 없는 타입으로 변환될 수 있으므로 result가 직관과 다르게 false가 될 수 있다.

부동소수점 타입

표준 부동소수점 타입은 float, double, long double이다.

float single = 1.0F;
double normal = 1.0;
long double extended = 1.0L;

각 타입은 다음 타입보다 정밀도와 표현 범위가 작거나 같다.

float
≤ double
≤ long double

이 관계가 모든 구현에서 서로 다른 메모리 크기를 뜻하지는 않는다. 일부 구현에서는 doublelong double이 같은 표현 형식을 사용할 수 있다.

부동소수점 타입의 구체적인 정밀도와 표현 범위는 std::numeric_limits로 확인할 수 있다.

#include <limits>

constexpr int double_digits =
    std::numeric_limits<double>::digits;

constexpr double smallest_positive =
    std::numeric_limits<double>::min();

constexpr double largest =
    std::numeric_limits<double>::max();

부동소수점 값은 많은 십진수를 정확히 표현할 수 없다.

double value = 0.1 + 0.2;

bool exact = value == 0.3;

이 비교가 false가 될 수 있는 이유는 0.1, 0.2, 0.3이 일반적인 이진 부동소수점 형식에서 정확히 표현되지 않기 때문이다.

허용 오차를 사용하는 비교는 값의 규모와 계산 특성에 맞게 설계해야 한다.

#include <algorithm>
#include <cmath>

bool nearly_equal(double left, double right, double epsilon)
{
    double scale = std::max({
        1.0,
        std::abs(left),
        std::abs(right)
    });

    return std::abs(left - right) <= epsilon * scale;
}

복합 타입

복합 타입은 하나 이상의 기존 타입이나 선언을 바탕으로 구성된다. C++ 표준의 복합 타입에는 배열, 함수, 포인터, 참조, 클래스, 공용체, 열거형과 멤버 포인터 등이 포함된다.[30]

C++ 복합 타입
  • 복합 타입
    • 배열 타입
    • 함수 타입
    • 포인터 타입
      • 객체 포인터
      • 함수 포인터
    • 참조 타입
      • lvalue 참조
      • rvalue 참조
    • 클래스 타입
      • 클래스
      • 구조체
      • 공용체
    • 열거형 타입
    • 멤버 포인터 타입
      • 데이터 멤버 포인터
      • 멤버 함수 포인터

배열 타입

배열 타입은 같은 타입의 원소가 연속적으로 배치된 고정 크기 객체를 표현한다.

int values[4] {10, 20, 30, 40};

values의 타입은 “원소 네 개를 가진 int 배열”이다. 배열의 크기는 타입의 일부다.

int first[3];
int second[4];

// first와 second는 서로 다른 배열 타입이다.

배열은 많은 표현식에서 첫 번째 원소를 가리키는 포인터로 변환된다.

int values[3] {10, 20, 30};

int* pointer = values;

그러나 배열과 포인터는 같은 타입이 아니다.

int values[3];

static_assert(sizeof(values) == sizeof(int) * 3);

int* pointer = values;
static_assert(sizeof(pointer) == sizeof(int*));

배열을 참조로 받으면 배열 크기를 타입 정보로 유지할 수 있다.

#include <cstddef>

template<class T, std::size_t Size>
constexpr std::size_t array_size(const T (&)[Size])
{
    return Size;
}

int values[5];

static_assert(array_size(values) == 5);

일반적인 코드에서는 소유 배열로 std::array, 동적 크기 배열로 std::vector, 비소유 연속 범위로 std::span을 사용할 수 있다.

#include <array>
#include <span>
#include <vector>

std::array<int, 3> fixed {1, 2, 3};
std::vector<int> dynamic {1, 2, 3, 4};

std::span<int> view = dynamic;

함수 타입

함수도 타입을 가진다. 함수 타입은 반환형과 매개변수 타입 목록, 가변 인자 여부, 일부 한정과 예외 명세로 구성된다.

int add(int left, int right);

addint 두 개를 받아 int를 반환하는 함수다.

함수 타입은 타입 별칭으로 표현할 수 있다.

using BinaryOperation = int(int, int);

BinaryOperation add;
BinaryOperation subtract;

함수 객체 자체는 일반 객체처럼 복사하거나 배열에 저장할 수 없지만, 함수 포인터와 함수 참조를 사용할 수 있다.

int add(int left, int right)
{
    return left + right;
}

using Operation = int(int, int);

Operation& operation = add;

int result = operation(20, 22);

포인터 타입

포인터는 객체나 함수의 주소를 나타내는 값이다.

int value = 42;
int* pointer = &value;

int result = *pointer;

포인터 타입은 가리키는 대상 타입을 포함한다.

int* integer_pointer = nullptr;
double* double_pointer = nullptr;

// 서로 다른 타입이다.

포인터 자체도 객체이므로 별도의 주소와 수명을 가진다.

int value = 42;
int* pointer = &value;
int** pointer_to_pointer = &pointer;

포인터는 널 포인터 값, 유효한 객체 또는 함수의 주소, 객체의 끝 다음 위치, 또는 유효하지 않은 포인터 값을 가질 수 있다.

배열 반복에서는 끝 다음 포인터를 비교용으로 사용할 수 있지만 역참조할 수 없다.

int values[3] {10, 20, 30};

int* begin = values;
int* end = values + 3;

for (int* current = begin; current != end; ++current) {
    int value = *current;
}

참조 타입

참조는 기존 객체나 함수의 별칭을 나타낸다. C++에는 lvalue 참조 T&와 rvalue 참조 T&&가 있다.

int value = 42;

int& lvalue_reference = value;
int&& rvalue_reference = 10;

참조는 초기화된 뒤 다른 객체를 가리키도록 다시 지정할 수 없다.

int first = 10;
int second = 20;

int& reference = first;

reference = second;

마지막 문장은 참조 대상을 second로 바꾸는 것이 아니라 firstsecond의 값인 20을 대입한다.

널 참조라는 정상적인 참조 값은 존재하지 않는다.

int* pointer = nullptr;

// int& reference = *pointer;
// 역참조 과정에서 유효한 객체가 없으므로 사용할 수 없다.

참조는 객체가 아니므로 일반적인 의미의 참조 배열이나 참조 포인터을 만들 수 없다.

// int& references[3]; // 오류
// int&* pointer;      // 오류

대신 참조를 보관해야 한다면 std::reference_wrapper를 사용할 수 있다.

#include <functional>
#include <vector>

int first = 10;
int second = 20;

std::vector<std::reference_wrapper<int>> references {
    first,
    second
};

references[0].get() = 100;

멤버 포인터 타입

멤버 포인터는 특정 클래스의 비정적 데이터 멤버나 멤버 함수를 식별한다. 일반 객체 포인터와는 다른 타입 체계를 가진다.

struct Position {
    int x;
    int y;
};

int Position::* member = &Position::x;

Position position {10, 20};

int value = position.*member;

객체 포인터와 함께 사용할 때는 ->* 연산자를 사용한다.

Position* pointer = &position;

int value = pointer->*member;

멤버 함수 포인터도 클래스 타입을 포함한다.

struct Device {
    void start();
    void stop();
};

void (Device::* action)() = &Device::start;

Device device;
(device.*action)();

멤버 포인터의 표현 방식은 상속 구조와 ABI에 따라 일반 주소보다 복잡할 수 있다.

사용자 정의 타입

C++에서 프로그램이 직접 정의할 수 있는 주요 타입은 클래스, 구조체, 공용체와 열거형이다. 타입 별칭을 통해 기존 타입에 새로운 이름을 부여할 수도 있다.

클래스와 구조체

classstruct는 모두 클래스 타입을 정의한다. 두 키워드의 언어적 차이는 기본 접근 수준과 기본 상속 접근 수준이다.

class ClassType {
    int hidden;
};

struct StructType {
    int visible;
};

class의 멤버는 기본적으로 private, struct의 멤버는 기본적으로 public이다.

#include <string>

class User {
    std::string name_;
    int level_;

public:
    User(std::string name, int level)
        : name_(std::move(name)),
          level_(level)
    {
    }

    const std::string& name() const
    {
        return name_;
    }

    int level() const
    {
        return level_;
    }
};

클래스 타입은 데이터 표현과 동작, 불변 조건, 생성과 파괴 규칙을 하나의 타입으로 묶을 수 있다.

공용체

공용체는 여러 비정적 데이터 멤버가 같은 저장 공간을 공유하는 클래스 타입이다. 일반적으로 한 시점에는 하나의 멤버만 활성 상태가 된다.

union Number {
    int integer;
    double real;
};

Number number;
number.integer = 42;

이후 number.real을 읽는 것은 일반적으로 활성 멤버와 다른 멤버에 접근하는 것이므로 제한된 예외를 제외하면 올바른 타입 판별 방식이 아니다.

활성 타입을 안전하게 추적해야 한다면 std::variant를 사용할 수 있다.

#include <string>
#include <variant>

using Value = std::variant<
    int,
    double,
    std::string
>;

Value value = 42;
value = std::string("C++");

열거형

열거형은 이름 있는 상수의 집합을 정의하는 별개의 타입이다.

enum Direction {
    North,
    South,
    East,
    West
};

범위 없는 열거형의 열거자는 바깥 범위로 들어가며 정수 타입으로 암시적 변환될 수 있다.

Direction direction = North;
int value = direction;

범위 있는 열거형 enum class는 열거자를 열거형 범위 안에 유지하며 정수로 암시적 변환되지 않는다.

enum class Status : unsigned char {
    Idle,
    Running,
    Finished
};

Status status = Status::Running;

// int value = status; // 오류

필요한 경우 명시적으로 기반 타입으로 변환할 수 있다.

int value = static_cast<int>(status);

열거형의 기반 타입을 지정하면 저장 표현과 외부 인터페이스를 더 명확하게 관리할 수 있다.

타입 별칭

usingtypedef는 기존 타입에 별칭을 부여한다. 새로운 별개의 타입을 만드는 것은 아니다.

using UserId = unsigned long long;

UserId first = 100;
unsigned long long second = first;

UserIdunsigned long long은 같은 타입이다. 서로 다른 논리적 의미를 컴파일러가 구별하게 하려면 별도의 클래스 타입을 정의할 수 있다.

class UserId {
    unsigned long long value_;

public:
    explicit UserId(unsigned long long value)
        : value_(value)
    {
    }

    unsigned long long value() const
    {
        return value_;
    }
};

class ProductId {
    unsigned long long value_;

public:
    explicit ProductId(unsigned long long value)
        : value_(value)
    {
    }
};

이제 UserIdProductId는 저장 표현이 비슷하더라도 서로 다른 타입이다.

불완전 타입

크기와 전체 정의가 아직 알려지지 않은 타입을 불완전 타입이라고 한다. void, 경계가 알려지지 않은 배열과 선언만 된 클래스 등이 이에 해당한다.

class Renderer;

Renderer* create_renderer();
void destroy_renderer(Renderer* renderer);

Renderer의 전체 정의가 없어도 포인터나 참조를 선언할 수 있다. 그러나 객체를 직접 만들거나 sizeof를 적용하려면 완전한 정의가 필요하다.

class Renderer;

// Renderer renderer;          // 오류
// sizeof(Renderer);           // 오류

Renderer* pointer = nullptr;   // 가능
Renderer& get_renderer();      // 가능

전방 선언은 헤더 의존성을 줄이고 구현 세부 사항을 숨기는 데 사용된다.

// window.hpp

#include <memory>

class Window {
    class Impl;

    std::unique_ptr<Impl> impl_;

public:
    Window();
    ~Window();

    void show();
};

Impl의 정의는 소스 파일에 둘 수 있다.

// window.cpp

#include "window.hpp"

class Window::Impl {
public:
    void show_native_window()
    {
    }
};

Window::Window()
    : impl_(std::make_unique<Impl>())
{
}

Window::~Window() = default;

void Window::show()
{
    impl_->show_native_window();
}

constvolatile

C++는 타입에 constvolatile 한정자를 적용할 수 있다. 두 한정자를 합쳐 cv 한정자라고 한다.[31]

const

const로 한정된 객체는 해당 경로를 통해 수정할 수 없다.

const int value = 42;

// value = 100; // 오류

const는 객체 자체의 불변성을 항상 보장하는 전역적인 속성이 아니라, 특정 타입과 접근 경로에 적용되는 한정이다.

int value = 42;
const int* pointer = &value;

// *pointer = 100; // 오류

value = 100; // 다른 비 const 경로에서는 수정 가능

포인터에서는 포인터 자체와 가리키는 대상에 각각 const를 적용할 수 있다.

int first = 10;
int second = 20;

const int* pointer_to_const = &first;
int* const const_pointer = &first;
const int* const const_pointer_to_const = &first;

각 선언의 의미는 다음과 같다.

타입포인터 재지정대상 수정
const int*가능불가능
int* const불가능가능
const int* const불가능불가능

클래스의 const 멤버 함수는 const 객체에서도 호출할 수 있으며, 기본적으로 비정적 데이터 멤버를 수정할 수 없다.

class Counter {
    int value_ = 0;

public:
    int value() const
    {
        return value_;
    }

    void increment()
    {
        ++value_;
    }
};
const Counter counter;

int value = counter.value();

// counter.increment(); // 오류

논리적으로 객체의 외부 상태를 바꾸지 않는 캐시나 동기화 도구는 mutable로 선언할 수 있다.

#include <mutex>

class Cache {
    mutable std::mutex mutex_;
    mutable bool initialized_ = false;
    mutable int value_ = 0;

public:
    int value() const
    {
        std::lock_guard lock(mutex_);

        if (!initialized_) {
            value_ = 42;
            initialized_ = true;
        }

        return value_;
    }
};

volatile

volatile은 해당 객체에 대한 접근이 구현체가 관찰해야 하는 부수 효과일 수 있음을 나타낸다.

volatile unsigned int register_value = 0;

일반적인 컴파일러는 volatile 객체에 대한 읽기와 쓰기를 불필요한 일반 메모리 접근처럼 제거하지 않는다. 이 특성은 구현체가 지원하는 메모리 매핑 장치 레지스터나 신호 처리와 같은 제한된 상황에서 사용될 수 있다.

volatile unsigned int* const device_status =
    reinterpret_cast<volatile unsigned int*>(0x4000'0000);

unsigned int status = *device_status;

정확한 하드웨어 주소와 접근 의미는 C++ 표준이 아니라 대상 플랫폼과 구현체가 정의한다.

volatile은 스레드 동기화 수단이 아니다. 여러 스레드가 같은 일반 volatile 객체를 동시에 수정해도 원자성이나 메모리 순서가 보장되지 않는다.

volatile int counter = 0;

// 여러 스레드에서 ++counter를 실행해도 안전하지 않다.

스레드 간 공유 상태에는 std::atomic이나 뮤텍스 같은 동기화 수단을 사용한다.

#include <atomic>

std::atomic<int> counter = 0;

void increment()
{
    counter.fetch_add(1, std::memory_order_relaxed);
}

타입 동일성과 별칭

두 선언에 사용된 타입 별칭의 이름이 달라도 최종적으로 같은 타입을 가리키면 같은 타입으로 취급된다.

using Integer = int;
using Count = Integer;

static_assert(
    std::is_same_v<Count, int>
);

반면 구조가 같은 두 클래스는 서로 다른 타입이다.

struct First {
    int value;
};

struct Second {
    int value;
};

// First와 Second는 서로 다른 타입이다.

C++의 클래스와 열거형은 주로 선언된 이름과 정의에 의해 구별되는 명목적 타입이다. 멤버 구성이 우연히 같다고 해서 서로 대입할 수 있는 구조적 타입으로 취급되지 않는다.

First first {42};

// Second second = first; // 오류

필요한 변환은 생성자나 변환 함수를 통해 명시적으로 정의할 수 있다.

struct Second {
    int value;

    explicit Second(const First& first)
        : value(first.value)
    {
    }
};

Second second(first);

타입 변환

타입 변환은 표현식의 값을 다른 타입의 값으로 해석하거나 변환하는 과정이다. C++에는 암시적 변환과 명시적 변환이 있으며, 변환의 종류와 우선순위는 함수 오버로딩과 템플릿 선택에도 영향을 준다.[32]

암시적 변환

암시적 변환은 대입, 초기화, 함수 인자 전달, 반환과 연산자 적용 과정에서 별도의 캐스트 없이 수행된다.

int integer = 42;
double real = integer;

integer의 값은 double로 변환되어 real을 초기화한다.

정수 승격

bool, char, signed char, unsigned char, short 등의 좁은 정수 타입은 많은 산술 연산에서 int 또는 unsigned int로 승격된다.

unsigned char left = 200;
unsigned char right = 100;

auto result = left + right;

result의 타입은 일반적으로 unsigned char가 아니라 int다.

#include <type_traits>

static_assert(
    std::is_same_v<decltype(result), int>
);

일반적인 산술 변환

서로 다른 산술 타입이 하나의 연산에 참여하면 공통 타입을 결정하기 위한 변환이 수행된다.

int count = 3;
double scale = 0.5;

auto result = count * scale;

countdouble로 변환되므로 result의 타입은 double이다.

축소 변환

더 넓은 범위나 정밀도의 타입을 더 좁은 타입으로 변환하면 정보가 손실될 수 있다.

double real = 3.75;
int integer = real;

integer에는 소수 부분이 제거된 3이 저장된다.

중괄호 초기화는 여러 축소 변환을 컴파일 오류로 막는다.

double real = 3.75;

// int value {real}; // 오류: 축소 변환

컴파일 시 값이 정확히 표현될 수 있는 상수인 경우 일부 변환은 허용된다.

constexpr int value = 100;

unsigned char byte {value};

포인터 변환

파생 클래스 객체를 가리키는 포인터는 접근 가능한 기반 클래스 포인터로 변환될 수 있다.

class Base {
public:
    virtual ~Base() = default;
};

class Derived : public Base {
};

Derived derived;
Base* base = &derived;

객체 포인터는 void*로 변환될 수 있다.

int value = 42;

int* typed = &value;
void* raw = typed;

nullptr는 모든 포인터 타입의 널 포인터 값으로 변환될 수 있다.

int* object_pointer = nullptr;
void (*function_pointer)() = nullptr;

사용자 정의 변환

클래스는 단일 인자 생성자와 변환 함수를 통해 사용자 정의 변환을 제공할 수 있다.

class Distance {
    double meters_;

public:
    Distance(double meters)
        : meters_(meters)
    {
    }
};

Distance distance = 10.0;

이 생성자는 double에서 Distance로 암시적 변환을 허용한다.

암시적 변환을 원하지 않으면 explicit를 사용한다.

class Distance {
    double meters_;

public:
    explicit Distance(double meters)
        : meters_(meters)
    {
    }
};

// Distance distance = 10.0; // 오류
Distance distance(10.0);

변환 연산자는 클래스 객체를 다른 타입으로 변환할 수 있다.

class Handle {
    int value_;

public:
    explicit Handle(int value)
        : value_(value)
    {
    }

    explicit operator bool() const
    {
        return value_ >= 0;
    }
};

explicit operator bool은 조건 문맥에서는 사용할 수 있지만 일반적인 정수 변환으로 연쇄되지는 않는다.

Handle handle(10);

if (handle) {
}

// int value = handle; // 오류

명시적 캐스트

C++는 목적에 따라 구분된 네 가지 명명된 캐스트를 제공한다.

캐스트주된 용도
static_cast관련 타입 사이의 일반적인 명시적 변환
dynamic_cast다형적 클래스 계층의 런타임 검사 변환
const_castcv 한정자의 추가 또는 제거
reinterpret_cast저수준 표현 재해석

static_cast

double real = 3.75;
int integer = static_cast<int>(real);

열거형과 기반 정수 타입 사이의 변환에도 사용할 수 있다.

enum class Mode {
    Windowed = 1,
    Fullscreen = 2
};

int value = static_cast<int>(Mode::Fullscreen);

dynamic_cast

dynamic_cast는 다형적 클래스 계층에서 실제 객체 타입을 검사한다.

class Base {
public:
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void run()
    {
    }
};

void process(Base* base)
{
    if (Derived* derived = dynamic_cast<Derived*>(base)) {
        derived->run();
    }
}

포인터 변환에 실패하면 널 포인터를 반환한다. 참조 변환에 실패하면 std::bad_cast 예외를 던진다.

const_cast

const_cast는 타입의 const 또는 volatile 한정을 변경한다.

void legacy_api(char* text);

void call_legacy(const char* text)
{
    legacy_api(const_cast<char*>(text));
}

원래 객체가 실제로 const라면 한정을 제거한 포인터를 통해 수정하는 동작은 정의되지 않는다.

const int value = 42;

int* pointer = const_cast<int*>(&value);

// *pointer = 100;
// 실제 const 객체를 수정하므로 동작이 정의되지 않는다.

reinterpret_cast

reinterpret_cast는 포인터와 정수, 서로 다른 포인터 타입 등 저수준 표현 사이의 변환에 사용된다.

#include <cstdint>

int value = 42;
int* pointer = &value;

std::uintptr_t address =
    reinterpret_cast<std::uintptr_t>(pointer);

변환이 문법적으로 허용된다고 해서 변환된 타입을 통해 객체를 안전하게 읽을 수 있다는 뜻은 아니다. 정렬, 객체 수명, 표현 형식과 별칭 규칙을 함께 만족해야 한다.

타입 추론

C++는 변수 선언, 함수 반환형, 템플릿 인자와 클래스 템플릿 인자를 문맥에서 추론할 수 있다. 타입 추론은 동적 타입을 부여하는 기능이 아니라, 컴파일러가 정적 타입을 계산해 대신 작성하는 기능이다.

auto

auto는 초기화 표현식으로부터 변수의 타입을 추론한다.

auto integer = 42;
auto real = 3.14;
auto text = "C++";

추론 결과는 각각 int, double, const char*다.

#include <type_traits>

static_assert(
    std::is_same_v<decltype(integer), int>
);

static_assert(
    std::is_same_v<decltype(real), double>
);

static_assert(
    std::is_same_v<decltype(text), const char*>
);

일반 auto 추론은 템플릿 인자 추론과 유사하게 최상위 const와 참조를 제거할 수 있다.

const int value = 42;
const int& reference = value;

auto first = value;
auto second = reference;

firstsecond의 타입은 모두 int다.

const와 참조를 유지하려면 선언에 직접 작성한다.

const auto& first = value;
auto& second = value;

배열도 일반 auto 변수에서는 포인터로 변환된다.

int values[3] {1, 2, 3};

auto pointer = values;
auto& array = values;

pointerint*, arrayint (&)[3] 타입이다.

decltype

decltype은 표현식의 타입과 값 범주를 바탕으로 타입을 계산한다.

int value = 42;

decltype(value) first = 10;
decltype((value)) second = value;

괄호 없는 이름 표현식 decltype(value)는 선언된 타입인 int를 얻는다. 괄호를 추가한 decltype((value))는 lvalue 표현식의 규칙에 따라 int&가 된다.

#include <type_traits>

static_assert(
    std::is_same_v<decltype(value), int>
);

static_assert(
    std::is_same_v<decltype((value)), int&>
);

decltype(auto)는 반환 표현식의 타입과 값 범주를 더 직접적으로 보존할 때 사용한다.

int global_value = 42;

decltype(auto) get_value()
{
    return (global_value);
}

괄호 때문에 반환 표현식이 lvalue이며, 함수의 반환형은 int&가 된다.

함수 반환형 추론

함수 반환형에 auto를 사용하면 반환문으로부터 타입을 추론한다.

auto add(int left, int right)
{
    return left + right;
}

여러 반환문이 있다면 추론되는 타입이 서로 일치해야 한다.

auto select(bool condition)
{
    if (condition) {
        return 10;
    }

    // return 20.0;
    // int와 double이 서로 달라 오류가 발생한다.

    return 20;
}

후행 반환형은 매개변수 이름을 반환형 계산에 사용할 때 유용하다.

template<class Left, class Right>
auto multiply(const Left& left, const Right& right)
    -> decltype(left * right)
{
    return left * right;
}

템플릿 인자 추론

함수 템플릿을 호출하면 함수 인자와 매개변수 타입을 비교해 템플릿 인자를 추론한다.[33]

template<class T>
T maximum(T left, T right)
{
    return left < right ? right : left;
}

int integer = maximum(10, 20);
double real = maximum(1.5, 2.5);

첫 번째 호출에서는 Tint, 두 번째 호출에서는 double로 추론된다.

두 인자가 서로 다른 타입이면 하나의 T를 일관되게 추론할 수 없다.

// auto value = maximum(10, 20.5);
// 첫 번째 인자에서는 int, 두 번째에서는 double이 추론되어 충돌한다.

서로 다른 타입을 허용하도록 템플릿 매개변수를 분리할 수 있다.

#include <type_traits>

template<class Left, class Right>
auto maximum(Left left, Right right)
    -> std::common_type_t<Left, Right>
{
    using Result = std::common_type_t<Left, Right>;

    return left < right
        ? static_cast<Result>(right)
        : static_cast<Result>(left);
}

전달 참조

함수 템플릿에서 추론되는 T&&는 특정 조건에서 lvalue와 rvalue를 모두 받을 수 있는 전달 참조가 된다.

template<class T>
void forward_value(T&& value)
{
}

lvalue를 전달하면 T는 lvalue 참조로 추론되고, 참조 축약 규칙에 의해 최종 매개변수 타입도 lvalue 참조가 된다.

int value = 42;

forward_value(value);
forward_value(42);

원래 값 범주를 다른 함수로 전달하려면 std::forward를 사용한다.

#include <utility>

template<class T>
void relay(T&& value)
{
    consume(std::forward<T>(value));
}

클래스 템플릿 인자 추론

C++17부터 생성자 인자로부터 클래스 템플릿 인자를 추론할 수 있다.

#include <pair>
#include <vector>

std::pair pair(10, 20.5);

pair의 타입은 std::pair<int, double>로 추론된다.

사용자 정의 클래스에도 추론 가이드를 작성할 수 있다.

#include <string>

template<class T>
class Box {
    T value_;

public:
    explicit Box(T value)
        : value_(std::move(value))
    {
    }
};

Box(const char*) -> Box<std::string>;

Box first(42);
Box second("C++");

firstBox<int>, second는 명시한 추론 가이드에 따라 Box<std::string>이 된다.

템플릿 매개변수와 타입 의존성

템플릿은 타입, 상수 값과 다른 템플릿을 매개변수로 받을 수 있다.

#include <array>
#include <cstddef>

template<
    class T,
    std::size_t Size,
    template<class, std::size_t> class Container
>
class Storage;

일반적으로 다음 세 종류로 나뉜다.

  • 타입 템플릿 매개변수
  • 상수 템플릿 매개변수
  • 템플릿 템플릿 매개변수

타입 템플릿 매개변수

template<class T>
class Box {
    T value_;
};

class 대신 typename을 사용할 수 있다.

template<typename T>
class Box {
    T value_;
};

이 문맥에서 classtypename의 의미는 같다.

상수 템플릿 매개변수

상수 템플릿 매개변수는 컴파일 시간 값을 타입 구성에 포함한다.

#include <cstddef>

template<class T, std::size_t Size>
class FixedBuffer {
    T data_[Size];
};

FixedBuffer<int, 16> buffer;

FixedBuffer<int, 16>FixedBuffer<int, 32>는 서로 다른 타입이다.

FixedBuffer<int, 16> first;
FixedBuffer<int, 32> second;

// first = second; // 서로 다른 타입

템플릿 템플릿 매개변수

템플릿 자체를 매개변수로 받을 수도 있다.

#include <memory>
#include <vector>

template<
    class T,
    template<class, class> class Container
>
class Collection {
    Container<T, std::allocator<T>> values_;
};

Collection<int, std::vector> collection;

의존 타입

템플릿 안에서 템플릿 매개변수에 따라 달라지는 타입을 의존 타입이라고 한다.

template<class Container>
void process(const Container& container)
{
    typename Container::value_type value {};
}

Container::value_type이 타입이라는 사실은 템플릿 정의 시점에 확정되지 않으므로 typename을 붙인다.

의존 객체를 통해 멤버 템플릿을 호출할 때는 template 구분자가 필요할 수 있다.

template<class Object>
void call(Object& object)
{
    object.template convert<int>();
}

개념과 제약 조건

템플릿은 본래 사용된 연산이 인스턴스화 과정에서 유효한지를 검사했다. C++20의 개념requires는 템플릿 인자가 충족해야 하는 요구 사항을 명시적으로 표현한다.[34]

표준 개념

표준 라이브러리는 타입 관계와 연산 가능성을 표현하는 여러 개념을 제공한다.

#include <concepts>

template<std::integral T>
T double_value(T value)
{
    return value * 2;
}

이 함수는 정수 개념을 만족하는 타입만 허용한다.

int integer = double_value(10);
unsigned int natural = double_value(20U);

// double real = double_value(1.5);
// std::integral을 만족하지 않아 오류

여러 개념을 조합할 수 있다.

#include <concepts>

template<class T>
concept Number =
    std::integral<T> ||
    std::floating_point<T>;

template<Number T>
T square(T value)
{
    return value * value;
}

requires 표현식

requires 표현식은 특정 문법과 연산이 유효한지를 검사한다.

#include <concepts>

template<class T>
concept Addable = requires(T left, T right) {
    left + right;
};

표현식의 반환 타입까지 제한할 수 있다.

template<class T>
concept Ordered = requires(T left, T right) {
    { left < right } -> std::convertible_to<bool>;
};

중첩 요구 사항을 사용할 수도 있다.

#include <type_traits>

template<class T>
concept SmallObject = requires {
    requires sizeof(T) <= 16;
    requires std::is_trivially_copyable_v<T>;
};

requires

함수 선언 뒤에 requires 절을 붙일 수 있다.

#include <concepts>

template<class T>
T add(T left, T right)
    requires std::integral<T>
{
    return left + right;
}

앞쪽에 배치할 수도 있다.

template<class T>
requires std::floating_point<T>
T average(T left, T right)
{
    return (left + right) / static_cast<T>(2);
}

제약된 자리표시자 타입

auto에 개념을 적용해 변수나 함수 매개변수의 추론 타입을 제한할 수 있다.

#include <concepts>

std::integral auto count = 42;
std::floating_point auto ratio = 0.5;

함수 매개변수에 사용하면 축약 함수 템플릿이 된다.

#include <concepts>

auto multiply(
    std::integral auto left,
    std::integral auto right
)
{
    return left * right;
}

이는 개념적으로 별도의 템플릿 매개변수를 가진 함수 템플릿과 유사하다.

template<std::integral Left, std::integral Right>
auto multiply(Left left, Right right)
{
    return left * right;
}

개념은 단순한 문서용 주석이 아니다. 오버로드 후보 선택과 템플릿 부분 순서 결정에 참여한다.

#include <concepts>
#include <string>

template<class T>
std::string describe(const T&)
{
    return "value";
}

template<std::integral T>
std::string describe(const T&)
{
    return "integer";
}

정수 타입을 전달하면 더 구체적으로 제약된 오버로드가 선택된다.

타입 특성과 컴파일 시간 검사

표준 라이브러리의 <type_traits>는 타입의 분류와 속성을 검사하고, 새로운 타입으로 변환하기 위한 템플릿을 제공한다.[35]

타입 분류

#include <type_traits>

static_assert(std::is_integral_v<int>);
static_assert(std::is_floating_point_v<double>);
static_assert(std::is_pointer_v<int*>);
static_assert(std::is_reference_v<int&>);
static_assert(std::is_class_v<std::string>);

타입 관계

#include <type_traits>

class Base {
};

class Derived : public Base {
};

static_assert(std::is_same_v<int, int>);
static_assert(!std::is_same_v<int, long>);
static_assert(std::is_base_of_v<Base, Derived>);
static_assert(std::is_convertible_v<Derived*, Base*>);

타입 변환 특성

#include <type_traits>

using First = std::remove_const_t<const int>;
using Second = std::remove_reference_t<int&>;
using Third = std::add_pointer_t<double>;

static_assert(std::is_same_v<First, int>);
static_assert(std::is_same_v<Second, int>);
static_assert(std::is_same_v<Third, double*>);

참조와 cv 한정을 모두 제거하려면 std::remove_cvref_t를 사용할 수 있다.

#include <type_traits>

using Type =
    std::remove_cvref_t<const int&>;

static_assert(std::is_same_v<Type, int>);

호출 가능성 검사

#include <type_traits>

int add(int left, int right)
{
    return left + right;
}

static_assert(
    std::is_invocable_v<
        decltype(add),
        int,
        int
    >
);

static_assert(
    std::is_invocable_r_v<
        int,
        decltype(add),
        int,
        int
    >
);

static_assert

static_assert는 타입 관계와 컴파일 시간 조건을 검증한다.

#include <cstdint>
#include <type_traits>

struct PacketHeader {
    std::uint16_t type;
    std::uint16_t size;
};

static_assert(
    std::is_trivially_copyable_v<PacketHeader>
);

static_assert(
    sizeof(PacketHeader) == 4
);

두 번째 검사는 대상 ABI의 패딩과 타입 크기에 의존하므로, 외부 바이너리 형식과 연결할 때는 플랫폼 요구 사항을 함께 명시해야 한다.

타입 안전성과 저수준 제어

C++의 정적 타입 체계는 잘못된 함수 호출, 호환되지 않는 대입과 허용되지 않는 연산을 번역 시점에 탐지한다.

#include <string>

void set_count(int count);

set_count(10);

// set_count(std::string("ten"));
// 변환할 수 없는 타입이므로 오류

클래스와 열거형을 사용하면 서로 다른 개념을 타입 수준에서 분리할 수 있다.

struct Meters {
    double value;
};

struct Seconds {
    double value;
};

Meters distance {100.0};
Seconds time {10.0};

// distance = time; // 서로 다른 타입

동시에 C++는 포인터 변환, 공용체, 수동 객체 수명 관리와 reinterpret_cast 같은 저수준 기능도 제공한다. 이러한 기능은 운영체제, 드라이버, 직렬화, 외부 ABI와 메모리 관리 구현에 활용할 수 있지만, 타입 체계가 일반적으로 제공하는 검사를 일부 우회한다.

#include <cstddef>
#include <span>

void inspect_bytes(
    std::span<const std::byte> bytes
)
{
}

struct Header {
    int type;
    int size;
};

Header header {1, 128};

auto bytes = std::as_bytes(
    std::span(&header, 1)
);

inspect_bytes(bytes);

객체의 바이트 표현을 읽기 위해서는 임의의 포인터 재해석보다 std::as_bytes, std::bit_cast, std::memcpy와 같이 의도가 명확한 표준 기능을 사용할 수 있다.

#include <bit>
#include <cstdint>

float value = 1.0F;

std::uint32_t bits =
    std::bit_cast<std::uint32_t>(value);

std::bit_cast는 원본과 대상 타입의 크기가 같고 요구 조건을 만족할 때 객체 표현의 비트를 새 타입의 값으로 복사한다.

C++의 타입 체계는 고수준의 추상화와 저수준 메모리 접근을 하나의 언어 안에서 제공한다. 타입 추론과 템플릿을 사용해 반복적인 타입 표기를 줄일 수 있지만, 최종 타입은 여전히 컴파일 시점에 확정된다. 개념과 타입 특성을 사용하면 템플릿이 요구하는 연산과 속성을 명시할 수 있으며, 클래스와 강한 래퍼 타입을 사용하면 프로그램의 논리적 개념을 서로 구분된 타입으로 표현할 수 있다.

문법과 핵심 기능

C++ 프로그램은 선언, 표현식과 문장으로 구성된다. 선언은 이름과 타입, 함수, 클래스, 이름공간과 템플릿 등을 프로그램에 도입하고, 표현식은 값을 계산하거나 객체를 식별하며, 문장은 표현식의 실행 순서와 제어 흐름을 구성한다.

C++는 C (프로그래밍 언어)에서 계승한 블록과 표현식 중심 문법 위에 함수 오버로딩, 클래스, 생성자와 소멸자, 참조, 이름공간, 예외 처리, 템플릿과 람다 표현식 등을 결합한다. 이 기능들은 서로 독립된 부가 문법이 아니라 이름 탐색, 타입 검사, 오버로드 결정, 객체 수명과 표현식 평가 규칙을 통해 함께 작동한다.[36][37]

다음 프로그램은 C++의 기본적인 소스 구조를 보여준다.

#include <iostream>
#include <string>

// 클래스 선언과 정의
class Greeter {
    std::string name_;

public:
    explicit Greeter(std::string name)
        : name_(std::move(name))
    {
    }

    void greet() const
    {
        std::cout << "Hello, " << name_ << '\n';
    }
};

// 함수 정의
int main()
{
    // 지역 객체의 선언과 초기화
    Greeter greeter("C++");

    // 멤버 함수 호출 표현식
    greeter.greet();

    // 반환문
    return 0;
}

이 코드에는 헤더 포함을 처리하는 전처리 지시문, 클래스 정의, 데이터 멤버와 멤버 함수, 생성자, 지역 객체 선언, 함수 호출 표현식과 반환문이 포함되어 있다.

토큰과 기본 소스 문법

C++ 소스 코드는 전처리 토큰과 언어 토큰으로 분석된다. 식별자, 키워드, 리터럴, 연산자와 문장 부호는 프로그램 문법을 구성하는 기본 토큰이다.

int count = 42;

이 선언은 개념적으로 다음 토큰으로 나뉜다.

`int` `count` `=` `42` `;`

int는 키워드, count는 식별자, =는 연산자, 42는 정수 리터럴, ;는 문장의 끝을 나타내는 문장 부호다.

식별자

식별자는 변수, 함수, 타입, 이름공간과 템플릿 등에 붙이는 이름이다.

int player_count = 10;

void update_player()
{
}

class RenderDevice {
};

식별자는 대소문자를 구분한다.

int value = 10;
int Value = 20;
int VALUE = 30;

세 이름은 서로 다른 식별자다.

일부 식별자는 구현체와 표준 라이브러리를 위해 예약된다. 이중 밑줄이 포함된 이름과 밑줄로 시작한 뒤 대문자가 오는 이름은 모든 범위에서 구현체용으로 예약된다.

// 구현체와 충돌할 수 있으므로 프로그램에서 사용하지 않는다.

int __internal_value = 0;
int _SystemValue = 0;

전역 이름공간에서 밑줄로 시작하는 이름도 구현체를 위해 예약된다.[38]

키워드

키워드는 언어 문법에서 특별한 의미를 가지므로 일반 식별자로 사용할 수 없다.

// int class = 10;  // class는 키워드이므로 변수 이름으로 사용할 수 없다.
// int return = 20; // return도 키워드다.

C++의 키워드는 선언, 타입, 제어 흐름, 클래스, 템플릿, 예외와 코루틴 등 여러 문법 영역에 사용된다.

타입과 선언
`auto` `bool` `char` `class` `const` `double` `enum`
`float` `int` `long` `short` `signed` `struct`
`typename` `union` `unsigned` `void`

제어 흐름
`break` `case` `continue` `default` `do` `else`
`for` `goto` `if` `return` `switch` `while`

객체와 클래스
`delete` `explicit` `friend` `new` `operator`
`private` `protected` `public` `this` `virtual`

템플릿과 컴파일 시간
`concept` `consteval` `constexpr` `requires` `template`

예외
`catch` `noexcept` `throw` `try`

코루틴
`co_await` `co_return` `co_yield`

주석

한 줄 주석은 //, 블록 주석은 /**/를 사용한다.

// 이 주석은 줄 끝까지 이어진다.

int value = 42;

/*
이 주석은
여러 줄에 걸쳐 작성할 수 있다.
*/

블록 주석은 중첩되지 않는다.

/*
    바깥 주석

    // 한 줄 주석 문자는 블록 주석 안에서 특별한 의미가 없다.

    블록 주석 안에 또 다른 블록 주석의 시작 문자를
    사용하는 방식은 중첩 주석을 만들지 않는다.
*/

코드를 일시적으로 제외할 때는 중첩 문제를 피하기 위해 조건부 전처리를 사용할 수 있다.

#if 0
void old_implementation()
{
    /*
        내부 블록 주석도 그대로 유지할 수 있다.
    */
}
#endif

문장 종결과 블록

대부분의 선언과 표현식 문장은 세미콜론으로 끝난다.

int value = 42;
value += 10;

중괄호는 여러 문장을 하나의 복합문으로 묶는다.

if (value > 0) {
    int doubled = value * 2;
    value = doubled;
}

클래스 정의의 닫는 중괄호 뒤에는 세미콜론이 필요하다.

struct Position {
    int x;
    int y;
};

함수 정의의 닫는 중괄호 뒤에는 세미콜론을 붙이지 않는다.

int add(int left, int right)
{
    return left + right;
}

리터럴

리터럴은 소스 코드에 직접 작성한 값을 나타낸다. C++에는 정수, 부동소수점, 문자, 문자열, 불리언과 포인터 리터럴 등이 있다.[39]

정수 리터럴

int decimal = 42;
int binary = 0b101010;
int octal = 052;
int hexadecimal = 0x2A;

자릿수 구분 기호 '를 넣어 긴 숫자의 가독성을 높일 수 있다.

int million = 1'000'000;

unsigned int mask = 0b1111'0000'1010'0101;

접미사는 리터럴의 타입 선택에 영향을 준다.

auto first = 42U;    // unsigned int
auto second = 42L;   // long
auto third = 42LL;   // long long
auto fourth = 42ULL; // unsigned long long

부동소수점 리터럴

double first = 3.14;
double second = 1.0e6;
float third = 3.14F;
long double fourth = 3.14L;

16진 부동소수점 리터럴도 사용할 수 있다.

double value = 0x1.8p1;

// 16진수 1.8 × 2¹이므로 값은 3.0이다.

문자와 문자열 리터럴

char character = 'A';
const char* text = "C++";

wchar_t wide_character = L'A';
const wchar_t* wide_text = L"C++";

char8_t utf8_character = u8'A';
const char8_t* utf8_text = u8"C++";

char16_t utf16_character = u'A';
const char16_t* utf16_text = u"C++";

char32_t utf32_character = U'A';
const char32_t* utf32_text = U"C++";

문자열 리터럴은 널 종료 문자를 포함하는 상수 문자 배열이다.

static_assert(sizeof("C++") == 4);

// 'C', '+', '+', '\0'

이스케이프 시퀀스는 직접 입력하기 어려운 문자를 표현한다.

const char* text =
    "first line\n"
    "second line\tcolumn\n"
    "\"quoted text\"";

원시 문자열 리터럴은 역슬래시와 줄바꿈을 대부분 그대로 보존한다.

const char* json = R"json(
{
    "language": "C++",
    "path": "C:\projects\cpp"
}
)json";

불리언과 포인터 리터럴

bool enabled = true;
bool finished = false;

int* pointer = nullptr;

표현식과 연산자

표현식은 값을 계산하거나 객체 또는 함수를 식별하고, 경우에 따라 프로그램 상태를 변경하는 부수 효과를 발생시킨다. 리터럴, 이름, 함수 호출, 멤버 접근, 대입과 산술 연산 등은 모두 표현식의 일부가 될 수 있다.[40]

int left = 20;
int right = 22;

int result = left + right;

left + right는 두 객체의 값을 읽고 덧셈 결과를 계산하는 표현식이다.

기본 표현식

이름, 리터럴, this, 괄호 표현식과 람다 표현식 등은 다른 표현식을 구성하는 기본 요소가 된다.

int value = 42;

int first = value;
int second = (value);
int third = 10;

괄호는 표현식의 의미를 명확히 하거나 연산자 우선순위를 변경한다.

int first = 2 + 3 * 4;
int second = (2 + 3) * 4;

first14, second20이다.

산술 연산자

int left = 10;
int right = 3;

int sum = left + right;
int difference = left - right;
int product = left * right;
int quotient = left / right;
int remainder = left % right;

두 피연산자가 정수 타입이면 나눗셈 결과도 정수 타입이다.

int value = 10 / 3;

// value는 3이다.

부동소수점 나눗셈을 원하면 하나 이상의 피연산자를 부동소수점 타입으로 만든다.

double value = 10.0 / 3.0;

단항 +-는 피연산자의 부호를 표현한다.

int value = 10;

int positive = +value;
int negative = -value;

증가와 감소

전위 증가 연산자는 객체를 먼저 증가시키고 변경된 객체를 나타낸다.

int value = 10;
int result = ++value;

// value와 result는 모두 11이다.

후위 증가 연산자는 증가 전 값을 결과로 만들고 객체를 증가시킨다.

int value = 10;
int result = value++;

// result는 10, value는 11이다.

반환값이 필요하지 않다면 반복자와 사용자 정의 타입에서는 불필요한 이전 값 복사를 피하기 위해 전위 형태를 사용할 수 있다.

for (auto iterator = values.begin();
     iterator != values.end();
     ++iterator) {
    process(*iterator);
}

비교 연산자

bool equal = left == right;
bool not_equal = left != right;
bool less = left < right;
bool less_equal = left <= right;
bool greater = left > right;
bool greater_equal = left >= right;

C++20의 삼방향 비교 연산자 <=>는 두 값의 순서 관계를 하나의 결과로 표현한다.

#include <compare>

struct Version {
    int major;
    int minor;

    auto operator<=>(const Version&) const = default;
};

Version first {1, 0};
Version second {2, 0};

bool older = first < second;
bool equal = first == second;

기본 비교를 생성하면 컴파일러가 멤버 선언 순서대로 비교 연산을 구성할 수 있다.

논리 연산자

bool both = left_condition && right_condition;
bool either = left_condition || right_condition;
bool opposite = !left_condition;

&&||는 단축 평가를 수행한다.

bool is_positive(const int* pointer)
{
    return pointer != nullptr && *pointer > 0;
}

포인터가 널이면 오른쪽 피연산자를 평가하지 않으므로 역참조가 발생하지 않는다.

비트 연산자

unsigned int left = 0b1100;
unsigned int right = 0b1010;

unsigned int and_result = left & right;
unsigned int or_result = left | right;
unsigned int xor_result = left ^ right;
unsigned int inverted = ~left;

unsigned int shifted_left = left << 2;
unsigned int shifted_right = left >> 2;

비트 연산은 플래그, 하드웨어 레지스터, 압축된 데이터 형식과 저수준 알고리즘 등에 사용된다.

열거형 클래스로 플래그의 의미를 분리할 수 있다.

enum class Permission : unsigned int {
    None = 0,
    Read = 1U << 0,
    Write = 1U << 1,
    Execute = 1U << 2
};

constexpr Permission operator|(
    Permission left,
    Permission right
)
{
    return static_cast<Permission>(
        static_cast<unsigned int>(left) |
        static_cast<unsigned int>(right)
    );
}

Permission permissions =
    Permission::Read |
    Permission::Write;

대입 연산자

기본 대입 연산자는 오른쪽 값을 왼쪽 객체에 저장한다.

int value = 10;

value = 20;

복합 대입은 연산과 대입을 결합한다.

value += 10;
value -= 5;
value *= 2;
value /= 3;
value %= 4;

비트 연산에도 복합 대입이 존재한다.

unsigned int flags = 0;

flags |= 1U << 0;
flags &= ~(1U << 0);
flags ^= 1U << 1;
flags <<= 2;
flags >>= 1;

대입 표현식 자체도 값을 가지므로 연쇄 대입이 가능하다.

int first = 0;
int second = 0;

first = second = 42;

가독성과 오류 가능성을 고려해 조건식 내부의 대입은 의도가 명확한 경우에 한정할 수 있다.

int read_value();

if (int value = read_value(); value >= 0) {
    process(value);
}

조건 연산자

조건 연산자 ?:는 조건에 따라 두 표현식 중 하나를 평가한다.

int maximum =
    left > right
        ? left
        : right;

선택되지 않은 표현식은 평가되지 않는다.

int* pointer = nullptr;

int value =
    pointer != nullptr
        ? *pointer
        : 0;

쉼표 연산자

쉼표 연산자는 왼쪽 표현식을 먼저 평가한 뒤 오른쪽 표현식의 결과를 전체 결과로 사용한다.

int value = 0;

int result = (value = 10, value + 5);

value10, result15가 된다.

함수 인자나 선언자를 구분하는 쉼표는 쉼표 연산자와 다른 문법 요소다.

void function(int left, int right);

int first = 10, second = 20;

멤버 접근

객체의 멤버는 ., 객체 포인터의 멤버는 ->로 접근한다.

struct Position {
    int x;
    int y;
};

Position position {10, 20};
Position* pointer = &position;

int first = position.x;
int second = pointer->y;

pointer->y는 개념적으로 (*pointer).y와 같은 멤버에 접근한다.

첨자와 함수 호출

int values[3] {10, 20, 30};

int value = values[1];

int result = add(20, 22);

클래스는 operator[]operator()를 정의해 첨자와 함수 호출 문법을 제공할 수 있다.

class Multiplier {
    int factor_;

public:
    explicit Multiplier(int factor)
        : factor_(factor)
    {
    }

    int operator()(int value) const
    {
        return value * factor_;
    }
};

Multiplier double_value(2);

int result = double_value(21);

제어문

문장은 프로그램의 실행 흐름을 구성한다. C++에는 표현식문, 복합문, 선택문, 반복문, 점프문, 선언문과 예외 처리문 등이 있다.[41]

조건문

if

if (score >= 90) {
    grade = 'A';
}
else if (score >= 80) {
    grade = 'B';
}
else {
    grade = 'C';
}

조건은 bool로 문맥 변환될 수 있는 표현식이어야 한다.

if (pointer) {
    use(*pointer);
}

C++17부터 조건 앞에 초기화문을 둘 수 있다.

if (auto iterator = values.find(key);
    iterator != values.end()) {
    use(iterator->second);
}

iterator의 범위는 if와 연결된 else 문장까지로 제한된다.

객체의 성공 여부를 조건과 함께 검사할 수 있다.

#include <mutex>

std::unique_lock lock(mutex, std::try_to_lock);

if (lock.owns_lock()) {
    update_shared_state();
}

if constexpr

if constexpr는 템플릿 인스턴스화 시점에 조건을 평가하고 선택되지 않은 분기를 버린다.

#include <iostream>
#include <type_traits>

template<class T>
void print_value(const T& value)
{
    if constexpr (std::is_pointer_v<T>) {
        if (value) {
            std::cout << *value << '\n';
        }
    }
    else {
        std::cout << value << '\n';
    }
}

선택되지 않은 분기는 해당 템플릿 인스턴스의 실행 코드에 포함되지 않으며, 타입에 의존하는 일부 유효성 검사에서도 제외된다.

if consteval

C++23의 if consteval은 함수가 상수 평가 중 호출되었는지를 구분한다.

constexpr int calculate(int value)
{
    if consteval {
        return value * value;
    }
    else {
        return optimized_runtime_calculation(value);
    }
}

switch

switch는 정수형 또는 열거형 조건값에 따라 여러 분기 중 하나로 이동한다.

enum class Command {
    Start,
    Stop,
    Pause
};

void execute(Command command)
{
    switch (command) {
        case Command::Start:
            start();
            break;

        case Command::Stop:
            stop();
            break;

        case Command::Pause:
            pause();
            break;
    }
}

break가 없으면 다음 case로 실행이 이어질 수 있다.

switch (level) {
    case 3:
        enable_advanced_features();
        [[fallthrough]];

    case 2:
        enable_intermediate_features();
        [[fallthrough]];

    case 1:
        enable_basic_features();
        break;

    default:
        break;
}

의도적인 연속 실행에는 [[fallthrough]] 속성을 사용할 수 있다.

switch에도 초기화문을 둘 수 있다.

switch (int code = read_status(); code) {
    case 0:
        handle_success();
        break;

    default:
        handle_error(code);
        break;
}

반복문

while

while (running) {
    update();
    render();
}

조건은 각 반복 전에 평가된다.

do while

do {
    input = read_input();
}
while (!is_valid(input));

본문이 먼저 실행되므로 최소 한 번은 실행된다.

전통적인 for

for (int index = 0; index < 10; ++index) {
    process(index);
}

for 문은 초기화, 조건과 반복 표현식으로 구성된다.

for (초기화; 조건; 반복 표현식) {
    본문
}

각 요소는 필요에 따라 생략할 수 있다.

for (;;) {
    process_events();

    if (should_exit()) {
        break;
    }
}

범위 기반 for

#include <vector>

std::vector<int> values {10, 20, 30};

for (int value : values) {
    process(value);
}

복사를 피하고 원소를 수정하지 않으려면 const 참조를 사용할 수 있다.

for (const auto& value : values) {
    inspect(value);
}

원소를 수정하려면 비상수 참조를 사용한다.

for (auto& value : values) {
    value *= 2;
}

구조적 바인딩과 함께 사용할 수 있다.

#include <map>
#include <string>

std::map<std::string, int> versions {
    {"C++17", 2017},
    {"C++20", 2020},
    {"C++23", 2023}
};

for (const auto& [name, year] : versions) {
    print(name, year);
}

점프문

break

break는 가장 가까운 반복문이나 switch를 종료한다.

for (int value : values) {
    if (value < 0) {
        break;
    }

    process(value);
}

continue

continue는 현재 반복의 나머지 부분을 건너뛰고 다음 반복으로 이동한다.

for (int value : values) {
    if (value == 0) {
        continue;
    }

    process(100 / value);
}

return

return은 현재 함수를 종료하고 필요하면 값을 호출자에게 전달한다.

int absolute(int value)
{
    if (value < 0) {
        return -value;
    }

    return value;
}

반환형이 void인 함수에서는 값 없는 return을 사용할 수 있다.

void process(int value)
{
    if (value < 0) {
        return;
    }

    use(value);
}

goto

goto는 같은 함수 안에 있는 레이블로 제어를 이동한다.

void process()
{
    if (!initialize()) {
        goto cleanup;
    }

    run();

cleanup:
    release();
}

goto는 초기화되지 않은 객체의 범위 안으로 진입할 수 없으며, 블록 밖으로 나가면서 수명이 끝나는 자동 객체의 소멸자는 호출된다.

RAII와 함수 분리를 사용하면 대부분의 자원 정리 흐름을 직접적인 점프 없이 구성할 수 있다.

void process()
{
    Resource resource;

    if (!initialize()) {
        return;
    }

    run();
}

함수

함수는 이름, 반환형, 매개변수 목록과 함수 본문으로 구성되는 호출 가능한 개체다. 함수 선언은 호출에 필요한 타입 정보를 제공하고, 함수 정의는 실제 본문을 제공한다.[42]

int add(int left, int right)
{
    return left + right;
}

이 함수는 int 두 개를 값으로 받아 int를 반환한다.

함수 선언과 정의

// 선언
int multiply(int left, int right);

// 정의
int multiply(int left, int right)
{
    return left * right;
}

매개변수 이름은 함수 선언에서 생략할 수 있다.

int multiply(int, int);

정의에서는 본문에서 사용하려면 이름이 필요하다.

값 전달

void increment(int value)
{
    ++value;
}

int number = 10;
increment(number);

함수 매개변수 value는 인자의 값으로 초기화된 별도 객체이므로 number는 변경되지 않는다.

참조 전달

void increment(int& value)
{
    ++value;
}

int number = 10;
increment(number);

valuenumber의 별칭이므로 원본 객체가 변경된다.

읽기만 하는 큰 객체는 const 참조로 받을 수 있다.

#include <string>

void print_name(const std::string& name)
{
    print(name);
}

포인터 전달

void increment(int* value)
{
    if (value) {
        ++*value;
    }
}

int number = 10;
increment(&number);

포인터는 널 값을 가질 수 있으므로 선택적인 대상이나 배열 범위, C 인터페이스와 저수준 구조에 사용된다.

대상이 반드시 존재해야 한다는 계약은 참조로 더 직접적으로 표현할 수 있다.

반환값

#include <string>

std::string create_name()
{
    return "C++";
}

값 반환은 복사 생략과 이동 의미론을 통해 소유 객체를 전달하는 일반적인 방식이다.

내부 객체를 참조로 반환할 수도 있다.

class User {
    std::string name_;

public:
    const std::string& name() const
    {
        return name_;
    }
};

반환된 참조의 유효 기간은 원래 객체의 수명에 의존한다.

지역 객체의 참조나 포인터를 반환하면 안 된다.

const std::string& create_invalid_name()
{
    std::string name = "C++";

    return name;
}

함수 종료 시 name의 수명이 끝나므로 반환된 참조는 무효가 된다.

기본 인자

void open_window(
    int width = 1280,
    int height = 720,
    bool fullscreen = false
);

open_window();
open_window(1920, 1080);
open_window(1920, 1080, true);

기본 인자는 일반적으로 함수 호출 위치에서 사용 가능한 선언을 기준으로 결정된다. 한 범위에서 같은 매개변수의 기본 인자를 반복 정의할 수 없다.

가변 인자 템플릿

#include <iostream>

template<class... Values>
void print_all(const Values&... values)
{
    (std::cout << ... << values);
}

매개변수 팩과 폴드 표현식을 사용해 개수가 정해지지 않은 인자를 타입 안전하게 처리할 수 있다.

print_all("C++", ' ', 23, '\n');

constexpr 함수

constexpr int square(int value)
{
    return value * value;
}

static_assert(square(12) == 144);

상수 표현식 문맥에서는 번역 시간에 평가되며, 실행 시간 값에도 일반 함수처럼 사용할 수 있다.

consteval 함수

consteval int compile_time_square(int value)
{
    return value * value;
}

constexpr int value =
    compile_time_square(12);

consteval 함수 호출은 반드시 상수 표현식으로 평가되어야 한다.

noexcept

noexcept는 함수가 예외를 빠져나가게 하지 않는다는 명세를 표현한다.

void release_resource() noexcept
{
}

noexcept 함수 밖으로 예외가 전파되면 std::terminate가 호출된다.

조건부 noexcept도 사용할 수 있다.

#include <utility>

template<class T>
void swap_values(T& left, T& right)
    noexcept(noexcept(T(std::move(left))))
{
    T temporary(std::move(left));
    left = std::move(right);
    right = std::move(temporary);
}

함수 오버로딩

같은 범위에서 이름이 같고 매개변수 목록이 다른 여러 함수를 선언할 수 있다. 호출 시에는 이름 탐색으로 발견한 후보 가운데 인자와 가장 잘 맞는 함수를 오버로드 결정이 선택한다.[43]

void print(int value)
{
}

void print(double value)
{
}

void print(const char* value)
{
}
print(42);       // print(int)
print(3.14);     // print(double)
print("C++");    // print(const char*)

반환형만 다르게 해서는 오버로드할 수 없다.

// int calculate();
// double calculate();

// 오류: 호출 인자만으로 어느 함수를 선택할지 결정할 수 없다.

표준 변환 순위

정확히 일치하는 함수가 일반적으로 변환이 필요한 함수보다 우선한다.

void process(int value);
void process(double value);

process(10);

10은 이미 int이므로 process(int)가 선택된다.

void process(int value);
void process(long value);

char value = 'A';
process(value);

char에서 int로의 정수 승격이 다른 일반 변환보다 우선할 수 있다.

모호한 호출

두 후보가 같은 수준으로 적합하면 호출이 모호해진다.

void convert(long value);
void convert(unsigned long value);

// convert(10);
// int에서 두 타입으로의 변환 우열을 결정하지 못할 수 있다.

명시적 변환으로 의도를 정할 수 있다.

convert(static_cast<long>(10));

템플릿과 비템플릿 오버로드

#include <string>

void describe(int)
{
}

template<class T>
void describe(const T&)
{
}

describe(42);
describe(std::string("C++"));

42에는 정확히 맞는 비템플릿 함수가 선택되고, 문자열에는 함수 템플릿이 사용된다.

개념으로 템플릿 후보를 제한할 수 있다.

#include <concepts>

template<std::integral T>
void serialize(T value)
{
}

template<std::floating_point T>
void serialize(T value)
{
}

이름공간과 이름 탐색

이름공간은 관련된 선언을 하나의 명명된 범위에 묶고 서로 다른 라이브러리나 모듈의 이름 충돌을 줄인다.

namespace graphics {
    class Device {
    };

    void initialize()
    {
    }
}

범위 지정 연산자 ::로 이름에 접근한다.

graphics::Device device;

graphics::initialize();

중첩 이름공간

namespace atanara::renderer::vulkan {
    class Device {
    };
}

이는 여러 이름공간을 중첩해서 작성한 것과 같다.

namespace atanara {
    namespace renderer {
        namespace vulkan {
            class Device {
            };
        }
    }
}

이름공간 별칭

namespace renderer_backend =
    atanara::renderer::vulkan;

renderer_backend::Device device;

using 선언

특정 이름만 현재 범위에 도입할 수 있다.

using std::string;
using std::vector;

string name = "C++";
vector<int> values;

using namespace

using namespace std;

using namespace 지시문은 해당 이름공간의 이름을 비한정 이름 탐색에서 고려하게 한다. 넓은 범위, 특히 헤더 파일에서 사용하면 이후 추가된 이름과 충돌하거나 호출 후보가 예상보다 늘어날 수 있다.

void example()
{
    using namespace std;

    string name = "C++";
}

필요하다면 제한된 함수 범위에서만 사용할 수 있다.

이름의 범위

int global_value = 10;

void function()
{
    int local_value = 20;

    if (local_value > 0) {
        int block_value = 30;
    }

    // block_value는 여기서 보이지 않는다.
}

안쪽 범위의 이름은 바깥 범위의 같은 이름을 가릴 수 있다.

int value = 10;

void function()
{
    int value = 20;

    {
        int value = 30;
        print(value);
    }

    print(value);
}

전역 이름은 앞에 ::를 붙여 지정할 수 있다.

int value = 10;

void function()
{
    int value = 20;

    print(value);
    print(::value);
}

이름 탐색과 접근 검사

이름 사용이 처리될 때에는 먼저 이름 탐색과 필요한 오버로드 결정이 이루어지고, 그 뒤 접근 제어가 검사된다.[44]

class Device {
private:
    void reset()
    {
    }
};

void run(Device& device)
{
    // device.reset();
    // 이름은 찾을 수 있지만 private이므로 접근할 수 없다.
}

인자 의존 이름 탐색

함수 호출에서 비한정 함수 이름을 사용하면 일반 이름 탐색 외에 인자 타입과 관련된 이름공간도 검색할 수 있다. 이를 ADL 또는 Koenig lookup이라고 한다.

namespace geometry {
    struct Point {
        int x;
        int y;
    };

    void draw(const Point&)
    {
    }
}

geometry::Point point {10, 20};

draw(point);

draw가 현재 범위에 직접 선언되지 않았더라도 Point와 관련된 geometry 이름공간에서 찾을 수 있다.

사용자 정의 타입과 표준 타입 모두에 맞는 교환을 작성할 때 이 규칙을 활용할 수 있다.

#include <utility>

template<class T>
void exchange_values(T& left, T& right)
{
    using std::swap;

    swap(left, right);
}

타입과 같은 이름공간에 사용자 정의 swap이 있으면 ADL로 선택될 수 있고, 그렇지 않으면 std::swap이 후보가 된다.

클래스와 구조체

클래스는 데이터 멤버와 멤버 함수, 접근 제어, 생성과 파괴 규칙, 상속 관계를 하나의 사용자 정의 타입으로 묶는다. class, structunion은 모두 클래스 타입을 정의한다.[45]

#include <string>

class User {
    std::string name_;
    int level_ = 1;

public:
    User(std::string name, int level)
        : name_(std::move(name)),
          level_(level)
    {
    }

    const std::string& name() const
    {
        return name_;
    }

    int level() const
    {
        return level_;
    }
};

classstruct

두 키워드는 같은 종류의 타입을 정의한다. 차이는 기본 접근 수준과 기본 상속 접근 수준이다.

class ClassType {
    int value; // 기본적으로 private
};

struct StructType {
    int value; // 기본적으로 public
};

다음 두 선언은 접근 수준을 명시하면 같은 형태가 된다.

class First {
public:
    int value;
};

struct Second {
public:
    int value;
};

접근 제어

class Account {
private:
    long long balance_ = 0;

protected:
    void apply_internal_change(long long amount)
    {
        balance_ += amount;
    }

public:
    long long balance() const
    {
        return balance_;
    }

    void deposit(long long amount)
    {
        if (amount > 0) {
            balance_ += amount;
        }
    }
};
  • private 멤버는 클래스 자체와 허용된 친구에서 접근할 수 있다.
  • protected 멤버는 private 접근 범위에 더해 파생 클래스에서 접근할 수 있다.
  • public 멤버는 접근 가능한 객체를 통해 외부에서도 사용할 수 있다.

접근 제어는 객체의 표현을 숨기고 공개 인터페이스를 통해 불변 조건을 유지하는 데 사용된다.

데이터 멤버

struct Vector2 {
    float x = 0.0F;
    float y = 0.0F;
};

비정적 데이터 멤버는 각 객체마다 존재한다.

Vector2 first {10.0F, 20.0F};
Vector2 second {30.0F, 40.0F};

정적 데이터 멤버는 클래스 자체에 하나만 존재한다.

class Entity {
    inline static unsigned long long next_id_ = 1;

    unsigned long long id_ = next_id_++;

public:
    unsigned long long id() const
    {
        return id_;
    }
};

C++17부터 inline static 멤버를 클래스 정의 안에서 정의할 수 있다.

멤버 함수

class Counter {
    int value_ = 0;

public:
    void increment()
    {
        ++value_;
    }

    int value() const
    {
        return value_;
    }
};

비정적 멤버 함수 안에서는 현재 객체를 가리키는 this 포인터를 사용할 수 있다.

class Counter {
    int value_ = 0;

public:
    Counter& increment()
    {
        ++this->value_;

        return *this;
    }
};
Counter counter;

counter
    .increment()
    .increment()
    .increment();

정적 멤버 함수에는 현재 객체가 없으므로 this가 존재하지 않는다.

class Math {
public:
    static int square(int value)
    {
        return value * value;
    }
};

int result = Math::square(12);

클래스 외부의 멤버 정의

class Counter {
    int value_ = 0;

public:
    void increment();
    int value() const;
};

void Counter::increment()
{
    ++value_;
}

int Counter::value() const
{
    return value_;
}

범위 지정 연산자 Counter::는 정의가 Counter 클래스의 멤버임을 나타낸다.

친구 선언

친구 함수나 클래스는 클래스의 비공개 멤버에 접근할 수 있다.

#include <ostream>

class Vector2 {
    float x_;
    float y_;

public:
    Vector2(float x, float y)
        : x_(x),
          y_(y)
    {
    }

    friend std::ostream& operator<<(
        std::ostream& output,
        const Vector2& vector
    );
};

std::ostream& operator<<(
    std::ostream& output,
    const Vector2& vector
)
{
    return output
        << '('
        << vector.x_
        << ", "
        << vector.y_
        << ')';
}

친구 함수는 멤버 함수가 아니지만 명시적으로 허용된 비공개 접근 권한을 가진다.

생성자와 소멸자

생성자는 클래스 객체를 초기화하고, 소멸자는 객체의 수명이 끝날 때 정리 작업을 수행한다. 생성자는 반환형을 갖지 않으며 클래스 타입의 초기화를 담당한다.[46]

기본 생성자

class Counter {
    int value_;

public:
    Counter()
        : value_(0)
    {
    }
};

Counter counter;

인자 없이 호출할 수 있는 생성자를 기본 생성자라고 한다.

컴파일러가 생성하는 기본 생성자를 명시할 수 있다.

class Counter {
    int value_ = 0;

public:
    Counter() = default;
};

멤버 초기화 목록

#include <string>

class User {
    const unsigned long long id_;
    std::string name_;

public:
    User(
        unsigned long long id,
        std::string name
    )
        : id_(id),
          name_(std::move(name))
    {
    }
};

멤버 초기화 목록은 생성자 본문이 실행되기 전에 멤버와 기반 클래스 하위 객체를 초기화한다.

실제 초기화 순서는 초기화 목록에 작성한 순서가 아니라 클래스에 멤버가 선언된 순서를 따른다.

class Example {
    int first_;
    int second_;

public:
    Example()
        : second_(20),
          first_(10)
    {
    }
};

여기서도 first_가 먼저 초기화된다.

변환 생성자와 explicit

class Distance {
    double meters_;

public:
    Distance(double meters)
        : meters_(meters)
    {
    }
};

Distance distance = 10.0;

단일 인자로 호출 가능한 생성자는 다른 타입에서 클래스 타입으로의 암시적 변환에 참여할 수 있다.

class Distance {
    double meters_;

public:
    explicit Distance(double meters)
        : meters_(meters)
    {
    }
};

// Distance first = 10.0;
Distance second(10.0);
Distance third {10.0};

explicit는 의도하지 않은 암시적 변환을 막는다.

위임 생성자

class Window {
    int width_;
    int height_;
    bool fullscreen_;

public:
    Window()
        : Window(1280, 720, false)
    {
    }

    Window(int width, int height)
        : Window(width, height, false)
    {
    }

    Window(int width, int height, bool fullscreen)
        : width_(width),
          height_(height),
          fullscreen_(fullscreen)
    {
    }
};

하나의 생성자가 같은 클래스의 다른 생성자에게 초기화를 위임할 수 있다.

복사 생성자

class Buffer {
public:
    Buffer(const Buffer& other)
    {
        // other의 상태를 복제한다.
    }
};

복사 생성자는 같은 타입의 기존 객체를 바탕으로 새 객체를 만든다.

이동 생성자

class Buffer {
public:
    Buffer(Buffer&& other) noexcept
    {
        // other가 소유한 자원을 넘겨받는다.
    }
};

이동 생성자는 이동 가능한 객체의 자원을 재사용할 수 있다.

삭제된 함수

특정 생성이나 대입을 금지하려면 = delete를 사용한다.

class Mutex {
public:
    Mutex() = default;

    Mutex(const Mutex&) = delete;
    Mutex& operator=(const Mutex&) = delete;
};

복사 시도가 발생하면 컴파일 오류가 된다.

소멸자

class File {
    FileHandle handle_;

public:
    explicit File(const char* path)
        : handle_(open_file(path))
    {
    }

    ~File()
    {
        close_file(handle_);
    }
};

객체의 수명이 끝날 때 소멸자가 호출된다. 자동 객체는 블록을 벗어날 때, 동적 객체는 적절한 delete 또는 소유 객체의 정리 과정에서 파괴된다.

소멸자를 이용해 자원을 객체 수명에 연결하는 구조를 RAII라고 한다.

void write_document()
{
    File file("document.txt");

    file.write("C++");

    // 함수가 정상 종료되거나 예외로 빠져나가도
    // file의 소멸자가 호출된다.
}

가상 소멸자

기반 클래스 포인터로 파생 객체를 파괴하려면 기반 클래스의 소멸자가 가상이어야 한다.

class Base {
public:
    virtual ~Base() = default;
};

class Derived : public Base {
    Resource resource_;
};

Base* object = new Derived();

delete object;

가상 소멸자를 통해 Derived와 그 멤버가 올바르게 파괴된다.

상속과 다형성

상속은 기존 클래스의 멤버와 인터페이스를 바탕으로 새로운 클래스를 정의한다.

class Entity {
public:
    void update()
    {
    }
};

class Player : public Entity {
public:
    void jump()
    {
    }
};
Player player;

player.update();
player.jump();

상속 접근 수준

class PublicDerived : public Base {
};

class ProtectedDerived : protected Base {
};

class PrivateDerived : private Base {
};

공개 상속은 일반적으로 파생 객체가 기반 객체로 사용될 수 있는 is-a 관계를 표현한다.

class Shape {
};

class Circle : public Shape {
};

Circle circle;
Shape* shape = &circle;

함수 재정의

class Shape {
public:
    virtual double area() const
    {
        return 0.0;
    }

    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius_;

public:
    explicit Circle(double radius)
        : radius_(radius)
    {
    }

    double area() const override
    {
        return 3.141592653589793 *
               radius_ *
               radius_;
    }
};

override는 기반 클래스의 가상 함수를 실제로 재정의하는지 컴파일러가 검사하게 한다.

Circle circle(10.0);
Shape& shape = circle;

double result = shape.area();

기반 클래스 참조를 사용해도 실제 객체의 동적 타입에 따라 Circle::area()가 호출된다.

순수 가상 함수와 추상 클래스

class Renderer {
public:
    virtual void begin_frame() = 0;
    virtual void draw() = 0;
    virtual void end_frame() = 0;

    virtual ~Renderer() = default;
};

하나 이상의 순수 가상 함수를 가진 클래스는 추상 클래스이며 직접 객체를 만들 수 없다.

// Renderer renderer;

파생 클래스가 인터페이스를 구현한다.

class VulkanRenderer final : public Renderer {
public:
    void begin_frame() override
    {
    }

    void draw() override
    {
    }

    void end_frame() override
    {
    }
};

final

클래스에 final을 붙이면 더 이상 파생할 수 없다.

class FinalRenderer final : public Renderer {
};

가상 함수에 붙이면 이후 파생 클래스에서 다시 재정의할 수 없다.

class Base {
public:
    virtual void run();
};

class Derived : public Base {
public:
    void run() final;
};

다중 상속

class Drawable {
public:
    virtual void draw() = 0;
    virtual ~Drawable() = default;
};

class Serializable {
public:
    virtual void save() = 0;
    virtual ~Serializable() = default;
};

class Document :
    public Drawable,
    public Serializable {
public:
    void draw() override
    {
    }

    void save() override
    {
    }
};

하나의 클래스가 여러 기반 클래스를 상속할 수 있다.

같은 기반 클래스가 여러 경로로 중복될 때에는 가상 상속으로 하나의 공유 기반 하위 객체를 만들 수 있다.

class Object {
public:
    unsigned long long id = 0;
};

class VisibleObject : virtual public Object {
};

class SerializableObject : virtual public Object {
};

class Entity :
    public VisibleObject,
    public SerializableObject {
};

연산자 오버로딩

클래스나 열거형 타입에 대해 기존 C++ 연산자의 의미를 함수로 정의할 수 있다. 연산자 오버로딩은 새로운 연산자 기호를 만드는 기능이 아니며, 기존 연산자의 피연산자 수, 우선순위와 결합 방향을 바꾸지 않는다.[47]

산술 연산자

class Vector2 {
public:
    float x = 0.0F;
    float y = 0.0F;

    Vector2 operator+(const Vector2& other) const
    {
        return {
            x + other.x,
            y + other.y
        };
    }
};
Vector2 first {10.0F, 20.0F};
Vector2 second {30.0F, 40.0F};

Vector2 result = first + second;

이 표현식은 오버로드 결정 후 개념적으로 다음 멤버 함수 호출과 연결된다.

Vector2 result = first.operator+(second);

비멤버 함수로도 정의할 수 있다.

class Vector2 {
public:
    float x = 0.0F;
    float y = 0.0F;
};

Vector2 operator+(
    const Vector2& left,
    const Vector2& right
)
{
    return {
        left.x + right.x,
        left.y + right.y
    };
}

복합 대입

class Vector2 {
public:
    float x = 0.0F;
    float y = 0.0F;

    Vector2& operator+=(const Vector2& other)
    {
        x += other.x;
        y += other.y;

        return *this;
    }
};

Vector2 operator+(
    Vector2 left,
    const Vector2& right
)
{
    left += right;

    return left;
}

복합 대입을 핵심 구현으로 두고 이항 연산자를 구성하면 연산 의미의 중복을 줄일 수 있다.

비교 연산자

#include <compare>

struct Version {
    int major;
    int minor;
    int patch;

    auto operator<=>(const Version&) const = default;
};

컴파일러가 동등성과 순서 비교를 멤버 순서에 따라 생성한다.

첨자 연산자

#include <cstddef>

class FixedArray {
    int data_[4] {};

public:
    int& operator[](std::size_t index)
    {
        return data_[index];
    }

    const int& operator[](std::size_t index) const
    {
        return data_[index];
    }
};

상수 객체와 비상수 객체에 맞는 오버로드를 각각 제공할 수 있다.

함수 호출 연산자

class Clamp {
    int minimum_;
    int maximum_;

public:
    Clamp(int minimum, int maximum)
        : minimum_(minimum),
          maximum_(maximum)
    {
    }

    int operator()(int value) const
    {
        if (value < minimum_) {
            return minimum_;
        }

        if (value > maximum_) {
            return maximum_;
        }

        return value;
    }
};
Clamp percentage(0, 100);

int value = percentage(120);

operator()를 가진 객체를 함수 객체라고 부른다.

변환 연산자

class FileHandle {
    int descriptor_ = -1;

public:
    explicit operator bool() const noexcept
    {
        return descriptor_ >= 0;
    }
};
FileHandle file;

if (file) {
    use(file);
}

explicit 변환 함수는 조건 문맥에서 사용할 수 있지만 일반적인 암시적 변환 연쇄를 제한한다.

오버로딩할 수 없는 연산자

일부 문법 요소는 연산자 함수로 재정의할 수 없다.

범위 지정      `::`
직접 멤버 접근 `.`
멤버 포인터 접근 `.*`
조건 연산자    `?:`
`sizeof`
`typeid`
`alignof`
`noexcept`

&&||를 오버로드하면 내장 연산자의 단축 평가 의미를 그대로 얻지 못하므로, 논리 연산처럼 보이는 사용자 정의 연산자는 평가 특성을 함께 고려해야 한다.

람다 표현식

람다 표현식은 이름 없는 함수 객체를 소스 코드 안에서 직접 정의한다.

auto add = [](int left, int right) {
    return left + right;
};

int result = add(20, 22);

컴파일러는 람다마다 고유한 이름 없는 클래스 타입을 생성하며, 함수 본문은 그 타입의 함수 호출 연산자에 해당한다.

캡처

int factor = 2;

auto multiply = [factor](int value) {
    return value * factor;
};

factor를 값으로 캡처하므로 람다 객체 안에 복사본이 저장된다.

int total = 0;

auto add_to_total = [&total](int value) {
    total += value;
};

참조 캡처는 원래 객체를 수정할 수 있지만 람다 객체가 사용되는 동안 원본 객체가 살아 있어야 한다.

기본 캡처를 사용할 수 있다.

auto first = [=]() {
    return left + right;
};

auto second = [&]() {
    left += right;
};

필요한 이름을 명시적으로 캡처하면 객체 수명과 소유 관계를 더 직접적으로 확인할 수 있다.

초기화 캡처

#include <memory>

auto resource =
    std::make_unique<Resource>();

auto task = [
    owned = std::move(resource)
]() mutable {
    owned->run();
};

이동 전용 객체를 람다 안으로 이전할 수 있다.

일반 람다

auto maximum = [](const auto& left, const auto& right) {
    return left < right ? right : left;
};

매개변수의 auto는 람다 호출 연산자를 함수 템플릿처럼 만든다.

명시적인 템플릿 매개변수 목록도 사용할 수 있다.

auto convert = []<class Result, class Value>(
    Value&& value
) -> Result {
    return static_cast<Result>(
        std::forward<Value>(value)
    );
};

double value = convert.operator()<double>(42);

예외 처리

예외 처리는 함수의 일반 반환 흐름과 분리된 방식으로 오류나 특수 상태를 전달한다. throw 표현식이 예외 객체를 생성하면 현재 실행 지점에서 일치하는 처리기를 찾으며 호출 스택을 따라 제어가 이동한다.

이 과정에서 빠져나가는 범위의 자동 객체가 파괴되므로 RAII 기반 자원 정리가 수행된다.[48]

예외 던지기

#include <stdexcept>

double divide(double left, double right)
{
    if (right == 0.0) {
        throw std::invalid_argument(
            "division by zero"
        );
    }

    return left / right;
}

throw 뒤의 표현식으로 예외 객체를 초기화한다.

사용자 정의 예외 타입도 만들 수 있다.

#include <stdexcept>
#include <string>

class FileError : public std::runtime_error {
    std::string path_;

public:
    FileError(
        std::string path,
        std::string message
    )
        : std::runtime_error(std::move(message)),
          path_(std::move(path))
    {
    }

    const std::string& path() const noexcept
    {
        return path_;
    }
};

trycatch

try {
    double result = divide(10.0, 0.0);

    print(result);
}
catch (const std::invalid_argument& error) {
    print(error.what());
}

catch의 타입과 예외 객체가 일치하면 해당 처리기가 실행된다.

일반적으로 다형적 예외 객체의 복사를 피하고 실제 파생 타입을 보존하기 위해 const 참조로 받는다.

여러 처리기

try {
    load_document(path);
}
catch (const FileError& error) {
    report_file_error(
        error.path(),
        error.what()
    );
}
catch (const std::exception& error) {
    report_error(error.what());
}
catch (...) {
    report_unknown_error();
}

더 구체적인 파생 예외 처리기를 기반 클래스 처리기보다 먼저 배치한다.

catch (...)는 타입과 관계없이 나머지 예외를 처리한다.

다시 던지기

try {
    run_task();
}
catch (const std::exception& error) {
    log(error.what());

    throw;
}

피연산자 없는 throw;는 현재 처리 중인 예외 객체를 다시 던진다.

catch (const std::exception& error) {
    throw;       // 원래 예외 객체를 다시 던진다.
    // throw error; // 정적 타입 기준의 새 객체를 던질 수 있다.
}

스택 풀기와 RAII

class Transaction {
public:
    Transaction()
    {
        begin_transaction();
    }

    ~Transaction()
    {
        rollback_if_not_committed();
    }

    void commit()
    {
        commit_transaction();
    }
};

void update_database()
{
    Transaction transaction;

    modify_first_record();
    modify_second_record();

    transaction.commit();
}

중간 함수에서 예외가 발생하면 transaction의 소멸자가 호출되어 자원을 정리하거나 미완료 작업을 되돌릴 수 있다.

예외 전달과 스택 풀기
  1. 함수 실행 중 예외 객체 생성
  2. 현재 범위에서 처리기 검색
  3. 일치하는 처리기가 없으면 자동 객체 파괴
  4. 호출자 범위로 이동
  5. 일치하는 catch 처리기 실행

함수 시도 블록

생성자 초기화 과정에서 발생한 예외를 처리하기 위해 함수 시도 블록을 사용할 수 있다.

class Server {
    Socket socket_;

public:
    Server(const Address& address)
    try
        : socket_(address)
    {
    }
    catch (const SocketError& error) {
        log(error.what());

        throw;
    }
};

생성자 함수 시도 블록의 처리기가 실행될 때에는 이미 생성에 실패한 객체를 정상적으로 사용할 수 없다.

noexcept와 예외 경계

void shutdown() noexcept
{
    try {
        flush_logs();
        release_devices();
    }
    catch (...) {
        record_shutdown_failure();
    }
}

함수 밖으로 예외를 전달하지 않도록 내부에서 처리할 수 있다.

소멸자는 일반적으로 예외를 밖으로 전파하지 않도록 설계한다. 다른 예외로 스택을 푸는 중 소멸자에서 또 다른 예외가 빠져나가면 프로그램이 종료될 수 있다.

예외 외의 오류 표현

모든 오류가 예외로 표현되어야 하는 것은 아니다. 정상적으로 예상되는 실패나 즉시 분기해야 하는 결과는 반환값과 합 타입으로 표현할 수 있다.

#include <optional>

std::optional<int> find_value(
    const std::string& name
)
{
    if (!exists(name)) {
        return std::nullopt;
    }

    return read_value(name);
}

오류 정보가 필요하면 C++23의 std::expected를 사용할 수 있다.

#include <expected>
#include <string>

enum class ParseError {
    Empty,
    InvalidCharacter,
    OutOfRange
};

std::expected<int, ParseError>
parse_number(const std::string& text)
{
    if (text.empty()) {
        return std::unexpected(
            ParseError::Empty
        );
    }

    return parse_integer(text);
}

예외, std::optional, std::expected, 오류 코드와 단정은 서로 완전히 대체되는 기능이 아니다. 실패가 호출자에게 어떤 의미를 가지는지, 복구가 예상되는지, 호출 경계를 얼마나 이동해야 하는지에 따라 선택할 수 있다.

속성

속성은 선언이나 문장에 구현체와 도구가 해석할 수 있는 추가 정보를 부여한다.

[[nodiscard]]
int create_resource();

[[deprecated("use new_function instead")]]
void old_function();

[[maybe_unused]]
int debug_value = 42;

[[nodiscard]]

[[nodiscard]]
bool save_document()
{
    return write_file();
}

save_document();

반환값을 무시하면 구현체가 진단을 표시할 수 있다.

[[maybe_unused]]

[[maybe_unused]]
constexpr int debug_version = 23;

특정 빌드 구성에서 사용되지 않더라도 의도적인 미사용임을 나타낸다.

[[deprecated]]

[[deprecated("use connect_v2()")]]
void connect_v1()
{
}

기존 API를 즉시 제거하지 않고 새 API로의 이동을 안내할 수 있다.

[[likely]][[unlikely]]

if (request.is_valid()) [[likely]] {
    process(request);
}
else [[unlikely]] {
    report_invalid_request(request);
}

분기 가능성에 관한 최적화 힌트를 제공하지만 프로그램의 논리나 결과를 변경하지 않는다.

핵심 문법의 결합

C++의 핵심 기능은 각각 따로 사용되기보다 타입과 객체 수명, 오버로드 결정과 함께 결합된다.

다음 코드는 클래스, 생성자, RAII, 함수 오버로딩, 템플릿, 개념, 람다와 예외 처리를 하나의 작은 구조 안에서 사용한다.

#include <concepts>
#include <functional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

class TaskError : public std::runtime_error {
public:
    using std::runtime_error::runtime_error;
};

class TaskQueue {
    std::vector<std::function<void()>> tasks_;

public:
    TaskQueue() = default;

    TaskQueue(const TaskQueue&) = delete;
    TaskQueue& operator=(const TaskQueue&) = delete;

    template<std::invocable Function>
    void add(Function&& function)
    {
        tasks_.emplace_back(
            std::forward<Function>(function)
        );
    }

    void run()
    {
        for (auto& task : tasks_) {
            try {
                task();
            }
            catch (const std::exception& error) {
                throw TaskError(error.what());
            }
        }
    }

    std::size_t size() const noexcept
    {
        return tasks_.size();
    }
};

int main()
{
    TaskQueue queue;

    std::string message = "C++";

    queue.add([message] {
        print(message);
    });

    queue.add([] {
        perform_task();
    });

    try {
        queue.run();
    }
    catch (const TaskError& error) {
        report(error.what());

        return 1;
    }

    return 0;
}

TaskQueue는 복사를 삭제해 하나의 큐가 중복 소유되는 상황을 막는다. addstd::invocable 개념으로 호출 가능한 타입만 받아들이며, 전달 참조와 std::forward를 사용해 함수 객체를 저장한다. 람다는 작업을 함수 객체로 표현하고, run은 범위 기반 반복문과 예외 처리를 사용해 작업을 실행한다.

C++의 문법은 저수준 표현식과 제어문부터 클래스 기반 자원 관리, 정적 다형성, 동적 다형성과 예외 전달까지 하나의 언어 안에서 구성된다. 기능이 많지만 최종적으로는 선언된 이름을 찾고, 타입을 검사하고, 가능한 함수 후보 가운데 하나를 선택한 뒤, 객체 수명과 표현식 평가 규칙에 따라 실행한다는 공통 모델 위에서 작동한다.

프로그래밍 패러다임과 추상화

C++는 하나의 프로그래밍 패러다임만을 중심으로 설계된 언어가 아니다. 순차적인 명령과 함수를 중심으로 프로그램을 구성하는 절차형 프로그래밍, 데이터와 연산을 사용자 정의 타입으로 묶는 데이터 추상화, 클래스 계층과 가상 함수를 사용하는 객체 지향 프로그래밍, 타입과 연산을 매개변수화하는 제네릭 프로그래밍, 함수를 값처럼 조합하는 함수형 프로그래밍, 그리고 번역 과정에서 계산과 코드 생성을 수행하는 템플릿 메타프로그래밍과 컴파일 시간 프로그래밍을 함께 지원한다.

비야네 스트롭스트룹은 C++를 단순한 객체 지향 언어로 한정하지 않고, 서로 다른 문제에 적합한 여러 프로그래밍 기법을 조합할 수 있는 다중 패러다임 언어로 설명했다. 실제 C++ 프로그램은 절차형 코드, 클래스 기반 추상화, 템플릿, 함수 객체와 람다 표현식을 하나의 시스템 안에서 함께 사용하는 경우가 많다.[49][50]

C++에서 추상화는 하드웨어와 실행 비용을 완전히 숨기는 것보다, 사용자가 필요한 수준의 제어를 유지하면서 반복되는 구현 세부 사항을 타입과 인터페이스 뒤에 묶는 방향으로 발전했다. 클래스, 템플릿, 함수 객체, 개념과 표준 라이브러리는 서로 다른 방식으로 추상화를 제공하지만, 최종적으로는 C++ 추상 기계 위에서 객체와 함수, 표현식으로 실행된다.

C++의 주요 프로그래밍 패러다임
  • C++ 프로그래밍

절차형 프로그래밍

절차형 프로그래밍은 프로그램을 순서대로 실행되는 명령과 함수로 구성한다. C++는 C에서 블록, 표현식, 함수, 조건문과 반복문을 계승했으며, 클래스나 템플릿을 사용하지 않고도 완전한 프로그램을 작성할 수 있다.

#include <iostream>
#include <vector>

double average(const std::vector<int>& values)
{
    if (values.empty()) {
        return 0.0;
    }

    long long sum = 0;

    for (int value : values) {
        sum += value;
    }

    return static_cast<double>(sum) /
           static_cast<double>(values.size());
}

int main()
{
    std::vector<int> values {
        10,
        20,
        30,
        40
    };

    std::cout << average(values) << '\n';
}

이 프로그램은 입력 데이터를 함수에 전달하고, 반복문으로 값을 처리한 뒤 결과를 반환한다. 상태는 지역 변수에 저장되며 실행 순서는 함수 본문에 직접 나타난다.

절차형 설계는 다음과 같은 상황에서 직접적인 구조를 제공한다.

  • 입력을 일정한 순서로 변환하는 작업
  • 수학 계산과 알고리즘
  • 운영체제 및 하드웨어 API 호출
  • 상태가 적고 수명이 짧은 처리 과정
  • 클래스 계층이 필요하지 않은 독립 함수

함수를 더 작은 작업으로 나누면 처리 흐름을 명시적으로 유지하면서 구현을 분리할 수 있다.

#include <string>
#include <vector>

std::string read_source(const std::string& path);
std::vector<Token> tokenize(const std::string& source);
SyntaxTree parse(const std::vector<Token>& tokens);
Program generate(const SyntaxTree& tree);

Program compile(const std::string& path)
{
    std::string source = read_source(path);
    std::vector<Token> tokens = tokenize(source);
    SyntaxTree tree = parse(tokens);

    return generate(tree);
}

각 함수가 하나의 처리 단계를 담당하며, 데이터는 반환값과 함수 인자를 통해 다음 단계로 전달된다.

절차형 처리 흐름
  1. 입력 읽기
  2. 토큰 분석
  3. 구문 분석
  4. 중간 표현 생성
  5. 결과 반환

C++의 절차형 코드는 사용자 정의 타입, RAII와 표준 라이브러리를 함께 사용할 수 있다. 절차형 프로그래밍이 반드시 원시 포인터나 전역 상태를 사용한다는 뜻은 아니다.

#include <filesystem>
#include <fstream>
#include <string>

std::string read_file(
    const std::filesystem::path& path
)
{
    std::ifstream input(path);

    if (!input) {
        throw std::runtime_error(
            "파일을 열 수 없다."
        );
    }

    return {
        std::istreambuf_iterator<char>(input),
        std::istreambuf_iterator<char>()
    };
}

함수는 절차형이지만, 파일 자원은 std::ifstream의 객체 수명에 연결되어 자동으로 정리된다.

데이터 추상화

데이터 추상화는 데이터의 내부 표현과 그 데이터를 다루는 연산을 하나의 사용자 정의 타입으로 묶고, 외부 코드가 공개된 인터페이스를 통해서만 상태를 변경하게 하는 방식이다.

객체 지향 프로그래밍이 클래스 계층과 런타임 다형성을 강조한다면, 데이터 추상화는 상속이 없는 하나의 독립 타입에도 적용할 수 있다.

#include <stdexcept>

class BankAccount {
    long long balance_ = 0;

public:
    explicit BankAccount(long long initial_balance)
        : balance_(initial_balance)
    {
        if (initial_balance < 0) {
            throw std::invalid_argument(
                "초기 잔액은 음수일 수 없다."
            );
        }
    }

    long long balance() const noexcept
    {
        return balance_;
    }

    void deposit(long long amount)
    {
        if (amount <= 0) {
            throw std::invalid_argument(
                "입금액은 양수여야 한다."
            );
        }

        balance_ += amount;
    }

    bool withdraw(long long amount)
    {
        if (amount <= 0 || amount > balance_) {
            return false;
        }

        balance_ -= amount;

        return true;
    }
};

balance_는 외부에서 직접 수정할 수 없다. 생성자와 멤버 함수가 상태 변경 규칙을 검사하므로 다음 불변 조건을 유지할 수 있다.

`balance_ >= 0`

외부 코드는 저장 표현을 알 필요 없이 공개 인터페이스를 사용한다.

BankAccount account(1000);

account.deposit(500);

if (!account.withdraw(2000)) {
    report_insufficient_balance();
}

내부 표현은 공개 인터페이스가 유지되는 범위에서 변경할 수 있다.

#include <cstdint>

class BankAccount {
    std::int64_t balance_in_smallest_unit_ = 0;

public:
    // 공개 인터페이스는 유지하면서
    // 내부 표현을 별도 단위나 타입으로 바꿀 수 있다.
};

데이터 추상화는 다음 요소로 구성된다.

  • 의미가 있는 사용자 정의 타입
  • 객체 생성 시 유효한 상태 확립
  • 상태를 보호하는 접근 제어
  • 타입의 불변 조건을 유지하는 연산
  • 내부 표현과 외부 인터페이스의 분리
  • 객체 수명과 자원 수명의 결합

값 타입

C++의 클래스는 반드시 힙에 생성하거나 포인터를 통해 다뤄야 하는 참조형 객체가 아니다. 정수나 문자열처럼 복사하고 이동하며 컨테이너에 직접 저장할 수 있는 값 타입으로 설계할 수 있다.

#include <cmath>

class Vector2 {
    double x_ = 0.0;
    double y_ = 0.0;

public:
    constexpr Vector2() = default;

    constexpr Vector2(double x, double y)
        : x_(x),
          y_(y)
    {
    }

    constexpr double x() const noexcept
    {
        return x_;
    }

    constexpr double y() const noexcept
    {
        return y_;
    }

    constexpr Vector2& operator+=(
        const Vector2& other
    ) noexcept
    {
        x_ += other.x_;
        y_ += other.y_;

        return *this;
    }

    double length() const noexcept
    {
        return std::sqrt(
            x_ * x_ +
            y_ * y_
        );
    }
};

constexpr Vector2 operator+(
    Vector2 left,
    const Vector2& right
) noexcept
{
    left += right;

    return left;
}
Vector2 position(10.0, 20.0);
Vector2 velocity(1.0, 2.0);

position += velocity;

Vector2 next = position + velocity;

Vector2는 독립적인 값을 나타낸다. 객체를 복사하면 서로 독립된 두 값이 생기며, 별도의 동적 할당이나 참조 계수는 필요하지 않다.

RAII 추상화

자원 획득과 반환을 생성자와 소멸자에 연결하면 자원 사용 규칙을 타입 안에 캡슐화할 수 있다.

class File {
    NativeFileHandle handle_;

public:
    explicit File(const char* path)
        : handle_(open_native_file(path))
    {
        if (!is_valid(handle_)) {
            throw FileError(path);
        }
    }

    ~File()
    {
        close_native_file(handle_);
    }

    File(const File&) = delete;
    File& operator=(const File&) = delete;

    File(File&& other) noexcept
        : handle_(std::exchange(
              other.handle_,
              invalid_file_handle()
          ))
    {
    }

    void write(const void* data, std::size_t size)
    {
        write_native_file(handle_, data, size);
    }
};

외부 코드는 파일 핸들의 획득과 해제 순서를 반복해서 작성하지 않는다.

void save()
{
    File file("save.dat");

    file.write(data, size);

    // 정상 반환과 예외 전달 모두에서
    // File의 소멸자가 핸들을 반환한다.
}

객체 지향 프로그래밍

C++의 객체 지향 프로그래밍은 클래스 계층, 상속과 가상 함수를 사용해 공통 인터페이스 뒤에 여러 구현을 배치하는 방식이다. 기반 클래스의 포인터나 참조를 통해 파생 객체를 사용하며, 실제 호출할 함수는 객체의 동적 타입에 따라 실행 시간에 선택될 수 있다.

#include <memory>
#include <vector>

class Shape {
public:
    virtual double area() const = 0;
    virtual void draw(Renderer& renderer) const = 0;

    virtual ~Shape() = default;
};

class Circle final : public Shape {
    double radius_;

public:
    explicit Circle(double radius)
        : radius_(radius)
    {
    }

    double area() const override
    {
        return 3.141592653589793 *
               radius_ *
               radius_;
    }

    void draw(Renderer& renderer) const override
    {
        renderer.draw_circle(radius_);
    }
};

class Rectangle final : public Shape {
    double width_;
    double height_;

public:
    Rectangle(double width, double height)
        : width_(width),
          height_(height)
    {
    }

    double area() const override
    {
        return width_ * height_;
    }

    void draw(Renderer& renderer) const override
    {
        renderer.draw_rectangle(
            width_,
            height_
        );
    }
};

서로 다른 파생 객체를 같은 기반 클래스 인터페이스를 통해 다룰 수 있다.

std::vector<std::unique_ptr<Shape>> shapes;

shapes.push_back(
    std::make_unique<Circle>(10.0)
);

shapes.push_back(
    std::make_unique<Rectangle>(
        20.0,
        30.0
    )
);

for (const auto& shape : shapes) {
    shape->draw(renderer);
}

shape->draw(renderer)의 호출 대상은 각 객체의 동적 타입에 따라 결정된다.

가상 함수 호출 과정
  1. 기반 클래스 포인터 또는 참조
  2. 객체의 동적 타입 확인
  3. 최종 재정의 함수 선택
  4. 파생 클래스 구현 실행

런타임 다형성

가상 함수에 의한 다형성은 실행 시점에 교체 가능한 구현이 필요할 때 사용된다.

  • 플러그인 인터페이스
  • 렌더링 백엔드
  • 운영체제별 플랫폼 구현
  • 게임 객체와 UI 위젯 계층
  • 파일 시스템과 네트워크 추상화
  • 실행 중 종류가 결정되는 객체 집합
class RenderDevice {
public:
    virtual Buffer create_buffer(
        const BufferDescription& description
    ) = 0;

    virtual void submit(
        const CommandList& commands
    ) = 0;

    virtual ~RenderDevice() = default;
};
class VulkanRenderDevice final :
    public RenderDevice {
public:
    Buffer create_buffer(
        const BufferDescription& description
    ) override
    {
        return create_vulkan_buffer(description);
    }

    void submit(
        const CommandList& commands
    ) override
    {
        submit_vulkan_commands(commands);
    }
};

class D3D12RenderDevice final :
    public RenderDevice {
public:
    Buffer create_buffer(
        const BufferDescription& description
    ) override
    {
        return create_d3d12_buffer(description);
    }

    void submit(
        const CommandList& commands
    ) override
    {
        submit_d3d12_commands(commands);
    }
};

상위 시스템은 구체적인 그래픽스 API와 분리된다.

void render_frame(RenderDevice& device)
{
    Buffer buffer =
        device.create_buffer(description);

    record_commands(buffer);

    device.submit(commands);
}

인터페이스와 구현 상속

상속은 공통 인터페이스를 표현할 수도 있고, 기반 클래스의 구현을 재사용할 수도 있다. 두 목적은 서로 다르다.

순수 가상 함수 중심의 기반 클래스는 인터페이스에 가깝다.

class InputSource {
public:
    virtual InputState poll() = 0;
    virtual ~InputSource() = default;
};

기반 클래스에 상태와 구현을 포함할 수도 있다.

class TimedTask {
    Clock::time_point started_at_;

protected:
    TimedTask()
        : started_at_(Clock::now())
    {
    }

public:
    Duration elapsed() const
    {
        return Clock::now() - started_at_;
    }

    virtual void execute() = 0;
    virtual ~TimedTask() = default;
};

공통 구현이 실제로 같은 불변 조건과 수명 규칙을 공유하지 않는다면, 상속 대신 객체 합성을 사용할 수 있다.

class Timer {
public:
    Duration elapsed() const;
};

class DownloadTask {
    Timer timer_;
    NetworkClient client_;

public:
    void execute()
    {
        client_.download();

        log(timer_.elapsed());
    }
};

DownloadTaskTimer의 종류가 아니라 Timer를 소유하는 타입이므로 합성이 관계를 더 직접적으로 표현한다.

객체 슬라이싱

파생 객체를 기반 클래스 값으로 복사하면 파생 클래스 부분이 제거되는 객체 슬라이싱이 발생한다.

class Base {
public:
    virtual void run()
    {
    }
};

class Derived : public Base {
    int state_ = 42;

public:
    void run() override
    {
        use(state_);
    }
};

Derived derived;
Base base = derived;

base는 독립된 Base 객체이며 더 이상 Derived 객체가 아니다. 다형적 객체는 일반적으로 기반 클래스 포인터나 참조를 통해 전달한다.

void execute(Base& object)
{
    object.run();
}

execute(derived);

제네릭 프로그래밍

제네릭 프로그래밍은 특정 타입 하나에 묶이지 않는 알고리즘과 자료구조를 작성하고, 필요한 연산과 의미적 요구 사항을 만족하는 여러 타입에 적용하는 방식이다.

C++에서는 템플릿이 타입과 값, 다른 템플릿을 매개변수화하는 기본 수단이다. 템플릿을 체계적으로 사용하는 프로그래밍 방식을 제네릭 프로그래밍이라고 하며, 클래스 계층에 의한 런타임 다형성과 달리 많은 선택이 번역 시점에 이루어진다.[51][52]

함수 템플릿

template<class T>
const T& maximum(
    const T& left,
    const T& right
)
{
    return left < right
        ? right
        : left;
}

operator<를 제공하는 여러 타입에 같은 알고리즘을 적용할 수 있다.

int integer = maximum(10, 20);

double real = maximum(1.5, 3.5);

std::string text = maximum(
    std::string("C"),
    std::string("C++")
);

함수 호출 시 템플릿 인자는 인자로부터 추론된다.

클래스 템플릿

#include <cstddef>
#include <utility>

template<class T, std::size_t Capacity>
class StaticVector {
    T data_[Capacity];
    std::size_t size_ = 0;

public:
    constexpr std::size_t size() const noexcept
    {
        return size_;
    }

    constexpr std::size_t capacity() const noexcept
    {
        return Capacity;
    }

    void push_back(T value)
    {
        if (size_ >= Capacity) {
            throw std::length_error(
                "용량을 초과했다."
            );
        }

        data_[size_++] = std::move(value);
    }

    T& operator[](std::size_t index)
    {
        return data_[index];
    }

    const T& operator[](
        std::size_t index
    ) const
    {
        return data_[index];
    }
};
StaticVector<int, 16> integers;
StaticVector<std::string, 8> strings;

원소 타입과 용량이 타입의 일부가 된다.

요구 사항 중심 설계

제네릭 알고리즘은 특정 상속 계층보다 타입이 제공하는 연산과 의미에 의존한다.

template<class Range>
auto sum(const Range& range)
{
    using Value = typename Range::value_type;

    Value result {};

    for (const auto& value : range) {
        result += value;
    }

    return result;
}

이 함수는 다음 요구 사항을 암묵적으로 가진다.

  • Range::value_type이 존재해야 한다.
  • range를 범위 기반 for로 순회할 수 있어야 한다.
  • 원소를 Value에 더할 수 있어야 한다.
  • Value를 기본 초기화할 수 있어야 한다.

C++20의 개념을 사용하면 이러한 요구 사항을 인터페이스에 나타낼 수 있다.

#include <concepts>
#include <ranges>

template<class Range>
concept SummableRange =
    std::ranges::input_range<Range> &&
    requires(
        std::ranges::range_value_t<Range> result,
        std::ranges::range_reference_t<Range> value
    ) {
        { result += value }
            -> std::same_as<
                std::ranges::range_value_t<Range>&
            >;
    };
template<SummableRange Range>
auto sum(const Range& range)
{
    using Value =
        std::ranges::range_value_t<Range>;

    Value result {};

    for (const auto& value : range) {
        result += value;
    }

    return result;
}

개념은 템플릿 인자가 만족해야 하는 제약 조건을 명시하며, 제약은 오버로드 선택과 템플릿 특수화 선택에도 참여한다.[53]

정적 다형성

서로 다른 타입에 공통 문법을 적용하지만 가상 함수 테이블을 사용하지 않는 다형성을 정적 다형성이라고 부를 수 있다.

template<class Renderer>
void draw_scene(Renderer& renderer)
{
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
}
VulkanRenderer vulkan;
SoftwareRenderer software;

draw_scene(vulkan);
draw_scene(software);

각 타입에 맞는 함수가 컴파일 과정에서 생성되며, 호출 대상은 정적으로 결정된다.

개념으로 인터페이스를 표현할 수 있다.

template<class T>
concept Renderer = requires(T& renderer) {
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
};

template<Renderer T>
void draw_scene(T& renderer)
{
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
}

정책 기반 설계

동작의 일부를 템플릿 인자로 전달해 클래스를 조합할 수 있다.

struct ImmediateFlush {
    static void flush(Output& output)
    {
        output.flush();
    }
};

struct BufferedFlush {
    static void flush(Output&)
    {
    }
};

template<class FlushPolicy>
class Logger {
    Output output_;

public:
    void write(std::string_view message)
    {
        output_.write(message);

        FlushPolicy::flush(output_);
    }
};
Logger<ImmediateFlush> debug_logger;
Logger<BufferedFlush> file_logger;

상속 계층을 만들지 않고 컴파일 시간에 동작을 조합한다.

표준 템플릿 라이브러리

C++ 표준 라이브러리의 컨테이너, 반복자와 알고리즘은 제네릭 프로그래밍의 대표적인 사례다.

#include <algorithm>
#include <vector>

std::vector<int> values {
    4,
    1,
    3,
    2
};

std::sort(
    values.begin(),
    values.end()
);

std::sortstd::vector<int> 자체에 종속되지 않는다. 필요한 반복자 연산과 비교 가능성을 만족하는 다른 범위에도 적용할 수 있다.

사용자 정의 비교 함수를 전달할 수도 있다.

#include <algorithm>
#include <string>
#include <vector>

struct User {
    std::string name;
    int level;
};

std::vector<User> users;

std::sort(
    users.begin(),
    users.end(),
    [](const User& left, const User& right) {
        return left.level > right.level;
    }
);

알고리즘, 자료구조와 동작 정책이 각각 템플릿 인자로 분리된다.

함수형 프로그래밍

C++는 순수 함수형 언어는 아니지만, 함수를 값처럼 전달하고 반환하며, 고차 함수와 함수 합성, 불변 데이터와 지연 평가 같은 함수형 기법을 사용할 수 있다.

C++11의 람다 표현식은 지역 문맥에서 함수 객체를 직접 정의할 수 있게 했고, 일반 람다와 constexpr 람다, 범위 라이브러리는 함수형 데이터 처리 표현을 확장했다.[54]

순수 함수

동일한 인자에 대해 항상 같은 결과를 반환하고 외부 상태를 변경하지 않는 함수는 추론과 테스트가 쉽다.

constexpr int clamp(
    int value,
    int minimum,
    int maximum
)
{
    if (value < minimum) {
        return minimum;
    }

    if (value > maximum) {
        return maximum;
    }

    return value;
}

함수는 전역 상태를 읽거나 수정하지 않고 입력값만으로 결과를 만든다.

함수를 인자로 전달

template<class Range, class Function>
void for_each_value(
    Range&& range,
    Function&& function
)
{
    for (auto&& value : range) {
        function(value);
    }
}
std::vector<int> values {
    1,
    2,
    3
};

for_each_value(
    values,
    [](int value) {
        print(value);
    }
);

for_each_value는 다른 함수를 인자로 받는 고차 함수다.

함수를 반환

auto make_multiplier(int factor)
{
    return [factor](int value) {
        return value * factor;
    };
}
auto double_value = make_multiplier(2);
auto triple_value = make_multiplier(3);

int first = double_value(10);
int second = triple_value(10);

반환된 람다 객체는 factor의 값을 내부 상태로 보관한다.

함수 합성

#include <utility>

template<class First, class Second>
auto compose(First first, Second second)
{
    return [
        first = std::move(first),
        second = std::move(second)
    ](auto&& value) mutable
        -> decltype(auto) {
        return first(
            second(
                std::forward<decltype(value)>(
                    value
                )
            )
        );
    };
}
auto add_one = [](int value) {
    return value + 1;
};

auto square = [](int value) {
    return value * value;
};

auto square_then_add_one =
    compose(add_one, square);

int result =
    square_then_add_one(4);

// square(4) 이후 add_one(16)이 실행되어
// 결과는 17이다.

알고리즘과 람다

#include <algorithm>
#include <numeric>
#include <vector>

std::vector<int> values {
    1,
    2,
    3,
    4,
    5
};

std::vector<int> doubled;
doubled.reserve(values.size());

std::transform(
    values.begin(),
    values.end(),
    std::back_inserter(doubled),
    [](int value) {
        return value * 2;
    }
);

int total = std::accumulate(
    doubled.begin(),
    doubled.end(),
    0
);

데이터 변환 규칙은 람다로 전달되고 반복 구조는 표준 알고리즘이 담당한다.

범위와 지연 평가

C++20 범위 라이브러리의 뷰는 원본 범위를 소유하지 않으면서 필터링과 변환을 지연 적용할 수 있다.[55]

#include <ranges>
#include <vector>

std::vector<int> values {
    1,
    2,
    3,
    4,
    5,
    6
};

auto result =
    values
    | std::views::filter([](int value) {
          return value % 2 == 0;
      })
    | std::views::transform([](int value) {
          return value * value;
      });

result를 생성하는 시점에는 새 벡터가 만들어지지 않는다. 원소를 순회할 때 필터와 변환이 필요한 만큼 평가된다.

for (int value : result) {
    print(value);
}

실행 결과는 4, 16, 36이다.

범위 파이프라인의 데이터 처리
  1. 원본 범위에서 원소 요청
  2. 짝수 조건 검사
  3. 조건을 통과한 값만 전달
  4. 값의 제곱 계산
  5. 소비자에게 결과 전달

불변 값

객체를 수정하는 대신 새 값을 반환하는 방식으로 상태 변화를 표현할 수 있다.

struct Position {
    double x;
    double y;
};

constexpr Position moved(
    Position position,
    double offset_x,
    double offset_y
)
{
    return {
        position.x + offset_x,
        position.y + offset_y
    };
}
constexpr Position original {
    10.0,
    20.0
};

constexpr Position next =
    moved(original, 5.0, -2.0);

original은 변경되지 않고 next라는 새 값이 생성된다.

C++에서는 성능과 객체 정체성이 중요한 경우 가변 상태도 널리 사용되므로, 함수형 기법은 프로그램 전체에 강제되는 규칙이 아니라 선택 가능한 설계 도구다.

템플릿 메타프로그래밍

템플릿 메타프로그래밍은 템플릿 인스턴스화 규칙을 이용해 번역 과정에서 타입과 값을 계산하는 방식이다. 초기 C++에서는 컴파일 시간 계산을 위한 전용 실행 모델이 제한적이었기 때문에 재귀 템플릿과 특수화를 사용했다.

재귀 템플릿 계산

template<unsigned int Number>
struct Factorial {
    static constexpr unsigned long long value =
        Number *
        Factorial<Number - 1>::value;
};

template<>
struct Factorial<0> {
    static constexpr unsigned long long value = 1;
};

static_assert(Factorial<5>::value == 120);

Factorial<5>를 인스턴스화하면 Factorial<4>부터 Factorial<0>까지의 템플릿이 생성되며 결과가 번역 과정에서 계산된다.

타입 계산

template<bool Condition, class TrueType, class FalseType>
struct SelectType {
    using type = FalseType;
};

template<class TrueType, class FalseType>
struct SelectType<true, TrueType, FalseType> {
    using type = TrueType;
};
using Selected = SelectType<
    sizeof(void*) == 8,
    unsigned long long,
    unsigned int
>::type;

조건에 따라 결과 타입이 달라진다.

표준 라이브러리는 같은 용도의 std::conditional_t를 제공한다.

#include <type_traits>

using Selected = std::conditional_t<
    sizeof(void*) == 8,
    unsigned long long,
    unsigned int
>;

SFINAE

템플릿 인자 치환 과정에서 유효하지 않은 후보를 컴파일 오류로 즉시 처리하지 않고 오버로드 후보에서 제외하는 규칙을 SFINAE라고 한다.

#include <type_traits>

template<
    class T,
    std::enable_if_t<
        std::is_integral_v<T>,
        int
    > = 0
>
T double_value(T value)
{
    return value * 2;
}

현대 C++에서는 같은 제약을 개념으로 더 직접적으로 표현할 수 있다.

#include <concepts>

template<std::integral T>
T double_value(T value)
{
    return value * 2;
}

템플릿 메타프로그래밍은 타입 목록, 컴파일 시간 디스패치, 직렬화 구조 생성과 정적 인터페이스 검사 등에 사용된다. 현대 C++에서는 가능한 경우 constexpr, consteval, 개념과 일반 함수를 함께 사용해 표현을 단순화할 수 있다.

컴파일 시간 프로그래밍

컴파일 시간 프로그래밍은 프로그램 실행 전에 값 계산, 데이터 구조 생성과 유효성 검사를 수행한다. 템플릿 메타프로그래밍도 이에 포함되지만, 현대 C++에서는 일반 함수 문법을 사용하는 constexpr와 반드시 상수 평가되는 consteval을 사용할 수 있다.

constexpr로 선언된 함수는 인자와 평가 문맥이 허용하면 번역 과정에서 실행할 수 있다.[56]

constexpr 계산

constexpr unsigned long long factorial(
    unsigned int value
)
{
    unsigned long long result = 1;

    for (unsigned int number = 2;
         number <= value;
         ++number) {
        result *= number;
    }

    return result;
}

static_assert(factorial(5) == 120);

동일한 함수는 실행 시간 값에도 사용할 수 있다.

unsigned int value = read_value();

unsigned long long result =
    factorial(value);

컴파일 시간 테이블 생성

#include <array>
#include <cstddef>

template<std::size_t Size>
constexpr std::array<unsigned int, Size>
make_square_table()
{
    std::array<unsigned int, Size> result {};

    for (std::size_t index = 0;
         index < Size;
         ++index) {
        result[index] =
            static_cast<unsigned int>(
                index * index
            );
    }

    return result;
}

constexpr auto square_table =
    make_square_table<256>();

프로그램 실행 전에 테이블을 생성할 수 있다.

consteval

consteval unsigned long long
hash_literal(const char* text)
{
    unsigned long long hash =
        1469598103934665603ULL;

    while (*text != '\0') {
        hash ^=
            static_cast<unsigned char>(*text++);

        hash *= 1099511628211ULL;
    }

    return hash;
}

constexpr auto renderer_id =
    hash_literal("renderer");

consteval 함수는 반드시 상수 평가 과정에서 호출되어야 한다.

const char* runtime_text = read_text();

// auto value = hash_literal(runtime_text);
// 실행 시간 값이므로 호출할 수 없다.

정적 검사

#include <type_traits>

template<class T>
class PacketStorage {
    static_assert(
        std::is_trivially_copyable_v<T>,
        "패킷 타입은 바이트 단위 복사가 가능해야 한다."
    );

    T packet_;
};

타입의 요구 사항을 번역 시점에 확인할 수 있다.

개념을 사용하면 검사 조건을 인터페이스에 포함할 수 있다.

template<class T>
concept Packet =
    std::is_trivially_copyable_v<T> &&
    std::is_standard_layout_v<T>;

template<Packet T>
class PacketStorage {
    T packet_;
};

비용 없는 추상화

C++의 추상화 설계에서 중요한 원칙 중 하나는 비용 없는 추상화, 또는 무비용 추상화다. 이는 모든 추상화가 문자 그대로 기계어 명령 하나도 추가하지 않는다는 뜻이 아니다.

일반적으로 다음 두 방향을 의미한다.

  • 사용하지 않는 언어 기능에는 비용을 지불하지 않는다.
  • 적절하게 사용한 추상화는 같은 기능을 수작업으로 구현한 코드보다 본질적으로 더 큰 실행 시간이나 공간 비용을 요구하지 않아야 한다.

스트롭스트룹은 경량 추상화를 세심하게 직접 작성한 특정 구현보다 불필요한 시간 또는 공간 비용을 추가하지 않는 추상화로 설명했다.[57][58]

함수 인라인화

constexpr int square(int value) noexcept
{
    return value * value;
}

int calculate(int value)
{
    return square(value);
}

컴파일러는 함수 호출 자체를 제거하고 곱셈을 호출 위치에 직접 배치할 수 있다.

값 타입 추상화

struct Vector2 {
    float x;
    float y;

    constexpr Vector2 operator+(
        const Vector2& other
    ) const noexcept
    {
        return {
            x + other.x,
            y + other.y
        };
    }
};
Vector2 add(
    Vector2 left,
    Vector2 right
)
{
    return left + right;
}

최적화된 결과는 두 부동소수점 성분을 직접 더하는 코드와 유사하게 생성될 수 있다.

반복자 추상화

int sum(const std::vector<int>& values)
{
    int result = 0;

    for (auto iterator = values.begin();
         iterator != values.end();
         ++iterator) {
        result += *iterator;
    }

    return result;
}

반복자는 포인터와 유사한 추상 인터페이스를 제공하지만, 최적화된 빌드에서는 배열 주소를 직접 순회하는 코드와 유사한 결과를 만들 수 있다.

정적 다형성

template<class Operation>
int transform_value(
    int value,
    Operation operation
)
{
    return operation(value);
}

int result = transform_value(
    21,
    [](int value) {
        return value * 2;
    }
);

람다 타입과 호출 대상이 컴파일 시점에 알려져 있으므로 함수 객체 자체와 호출 경계가 제거될 수 있다.

동적 추상화의 명시적 비용

모든 추상화가 같은 비용 모델을 갖는 것은 아니다. 가상 함수는 런타임 다형성을 제공하기 위해 간접 호출과 객체별 동적 타입 표현을 사용할 수 있다.

class Operation {
public:
    virtual int apply(int value) const = 0;
    virtual ~Operation() = default;
};

int transform_value(
    int value,
    const Operation& operation
)
{
    return operation.apply(value);
}

실행 중 구현을 교체할 수 있다는 기능을 선택했기 때문에 그에 필요한 런타임 디스패치 비용이 발생할 수 있다. 이는 숨겨진 부가 비용이라기보다 선택한 기능의 구현 비용이다.

컴파일러가 실제 동적 타입을 확정할 수 있다면 가상 호출을 제거하는 비가상화 최적화를 적용할 수도 있다.

class DoubleOperation final :
    public Operation {
public:
    int apply(int value) const override
    {
        return value * 2;
    }
};

int calculate()
{
    DoubleOperation operation;

    return operation.apply(21);
}

추상화와 메모리 할당

추상화 자체가 반드시 동적 메모리 할당을 의미하지 않는다.

template<class Function>
void repeat(
    int count,
    Function function
)
{
    for (int index = 0;
         index < count;
         ++index) {
        function(index);
    }
}

repeat(
    10,
    [](int index) {
        process(index);
    }
);

템플릿과 람다는 일반적으로 스택 객체와 정적 호출만으로 구현할 수 있다.

반면 타입 소거를 제공하는 std::function은 저장 대상의 크기와 구현에 따라 동적 메모리를 사용할 수 있다.

#include <functional>

std::function<void(int)> callback =
    [state = LargeState {}](int value) {
        process(state, value);
    };

필요한 교체 가능성, 바이너리 경계와 객체 수명을 고려해 정적 템플릿과 동적 타입 소거 중 하나를 선택할 수 있다.

패러다임의 조합

실제 C++ 프로그램에서는 하나의 패러다임을 전체 코드에 일괄 적용하기보다 문제의 각 부분에 맞는 방식을 조합한다.

다음 구조는 절차형 실행 흐름, 데이터 추상화, 런타임 다형성, 제네릭 알고리즘과 함수형 콜백을 함께 사용한다.

#include <concepts>
#include <memory>
#include <ranges>
#include <string>
#include <utility>
#include <vector>

struct Frame {
    double delta_time;
};

class System {
public:
    virtual void update(const Frame& frame) = 0;
    virtual ~System() = default;
};

class PhysicsSystem final : public System {
    PhysicsWorld world_;

public:
    void update(const Frame& frame) override
    {
        world_.step(frame.delta_time);
    }
};

class AudioSystem final : public System {
    AudioMixer mixer_;

public:
    void update(const Frame& frame) override
    {
        mixer_.update(frame.delta_time);
    }
};

template<std::ranges::input_range Range>
void update_systems(
    Range&& systems,
    const Frame& frame
)
{
    for (auto&& system : systems) {
        system->update(frame);
    }
}

int main()
{
    std::vector<std::unique_ptr<System>> systems;

    systems.push_back(
        std::make_unique<PhysicsSystem>()
    );

    systems.push_back(
        std::make_unique<AudioSystem>()
    );

    while (!should_exit()) {
        Frame frame {
            .delta_time = calculate_delta_time()
        };

        update_systems(
            systems,
            frame
        );
    }
}

이 프로그램에서 사용된 패러다임은 다음과 같이 나뉜다.

  • main의 루프는 절차형 제어 흐름이다.
  • PhysicsWorld, AudioMixerFrame은 데이터 추상화와 값 타입을 사용한다.
  • System 계층은 런타임 다형성을 제공한다.
  • update_systems는 범위 개념을 사용하는 제네릭 함수다.
  • 각 시스템은 RAII를 통해 자신의 자원을 관리할 수 있다.

정적 다형성과 동적 다형성의 결합

상위 수준에서는 런타임 교체 가능성을 제공하고, 각 구현 내부에서는 템플릿으로 정적 최적화를 적용할 수 있다.

class ImageDecoder {
public:
    virtual Image decode(
        std::span<const std::byte> data
    ) const = 0;

    virtual ~ImageDecoder() = default;
};

template<class PixelConverter>
class PngDecoder final :
    public ImageDecoder {
    PixelConverter converter_;

public:
    explicit PngDecoder(
        PixelConverter converter
    )
        : converter_(std::move(converter))
    {
    }

    Image decode(
        std::span<const std::byte> data
    ) const override
    {
        return decode_png(
            data,
            converter_
        );
    }
};

ImageDecoder는 실행 중 디코더 구현을 선택할 수 있게 하고, PixelConverter는 각 구현 내부의 변환 정책을 컴파일 시점에 결정한다.

함수형 전처리와 객체 지향 실행

auto active_entities =
    entities
    | std::views::filter([](const Entity& entity) {
          return entity.is_active();
      });

for (Entity& entity : active_entities) {
    entity.update(frame);
}

범위 파이프라인은 처리 대상을 선언적으로 선택하고, 각 객체의 멤버 함수가 상태 변화와 동작을 수행한다.

값 타입과 인터페이스 타입

작고 독립적인 데이터는 값 타입으로 전달하고, 실행 중 교체할 서비스만 인터페이스로 추상화할 수 있다.

struct RenderCommand {
    MeshHandle mesh;
    MaterialHandle material;
    Matrix4 transform;
};

class RenderBackend {
public:
    virtual void submit(
        std::span<const RenderCommand> commands
    ) = 0;

    virtual ~RenderBackend() = default;
};

RenderCommand는 복사와 이동이 가능한 데이터 값이고, RenderBackend는 플랫폼별 구현을 교체하기 위한 동적 인터페이스다.

추상화 경계

추상화 경계는 구현 세부 사항이 외부 코드로 퍼지는 범위를 제한한다. 좋은 경계는 프로그램의 의미와 소유권, 오류, 수명과 비용 모델을 인터페이스에 드러낸다.

소유권 경계

std::unique_ptr<Texture>
load_texture(const std::filesystem::path& path);

반환형은 호출자가 새 텍스처의 단독 소유권을 받는다는 점을 나타낸다.

비소유 접근은 참조나 뷰로 표현할 수 있다.

void draw(const Texture& texture);

void upload(std::span<const std::byte> data);

오류 경계

#include <expected>

std::expected<Configuration, ParseError>
parse_configuration(std::string_view text);

호출자는 성공값과 오류를 모두 처리할 수 있다.

복구가 현재 호출 계층에서 예상되지 않는 실패는 예외로 전달할 수도 있다.

Database connect_database(
    const ConnectionOptions& options
);

비용 경계

값 복사 여부와 동적 디스패치 여부는 인터페이스에서 확인할 수 있게 구성할 수 있다.

void process_small_value(Vector2 value);

void process_large_value(
    const LargeDocument& document
);

void run_backend(RenderBackend& backend);

template<class Operation>
void apply_inline(Operation operation);

첫 번째 함수는 작은 값을 복사하고, 두 번째 함수는 큰 객체를 참조한다. 세 번째 함수는 런타임 다형성을 사용하며, 네 번째 함수는 호출 가능한 타입을 템플릿으로 받는다.

패러다임 선택의 기준

C++에서 패러다임은 상호 배타적인 언어 모드가 아니다. 하나의 타입이나 함수도 여러 기법을 동시에 사용할 수 있다.

요구 사항주로 사용하는 구조
명확한 순차 처리함수와 절차형 제어 흐름
유효한 상태를 보장하는 데이터클래스와 데이터 추상화
실행 중 구현 교체가상 함수와 타입 소거
여러 타입에 같은 알고리즘 적용템플릿과 개념
지역 동작 전달람다와 함수 객체
지연 데이터 처리범위와 뷰
실행 전 계산과 검증constexpr, consteval, 템플릿
자원의 자동 반환RAII
작은 독립 데이터값 타입
기능 조합객체 합성, 정책 타입, 함수 합성

C++의 다중 패러다임 성격은 같은 문제를 여러 문법으로 중복 해결하기 위한 것만은 아니다. 프로그램의 각 부분이 요구하는 교체 시점, 객체 수명, 소유권, 성능, 바이너리 경계와 확장 방식을 기준으로 추상화 수준을 선택할 수 있게 한다.

런타임에 종류가 바뀌는 객체에는 가상 함수와 타입 소거를 사용할 수 있고, 타입이 번역 시점에 확정되는 알고리즘에는 템플릿과 개념을 사용할 수 있다. 데이터는 값 타입과 불변 조건을 가진 클래스로 표현하고, 전체 처리 흐름은 함수와 반복문, 범위 파이프라인으로 구성할 수 있다.

C++에서 추상화는 저수준 제어를 제거하는 것이 아니라, 저수준 구현을 의미 있는 타입과 인터페이스 뒤에 배치하면서 필요한 경우 그 비용과 표현에 접근할 수 있게 하는 구조다.

자원과 메모리 관리

C++는 객체가 사용하는 저장 공간과 파일, 소켓, 잠금, 스레드, 그래픽스 객체와 같은 외부 자원을 직접 제어할 수 있다. 언어는 자동 객체와 정적 객체의 수명을 규정하고, 동적 저장 영역을 위한 newdelete, 객체 생성과 파괴를 위한 생성자와 소멸자를 제공한다. 표준 라이브러리는 이를 기반으로 컨테이너, 스마트 포인터, 할당자와 다형적 메모리 자원을 제공한다.

C++에서 메모리 관리는 저장 공간을 확보하고 반환하는 작업을 뜻하고, 자원 관리는 그보다 넓은 개념이다. 자원은 사용하기 전에 획득하고 사용이 끝난 뒤 반환해야 하는 모든 대상을 포함한다.

  • 동적으로 할당한 메모리
  • 파일과 파일 디스크립터
  • 네트워크 소켓
  • 운영체제 핸들
  • 뮤텍스와 읽기·쓰기 잠금
  • 데이터베이스 연결과 트랜잭션
  • 그래픽스 버퍼, 텍스처와 명령 목록
  • 오디오 장치와 스트림
  • 스레드와 비동기 작업
  • 임시 상태를 되돌리는 정리 작업

C++ 자원 관리의 중심은 자원의 수명을 객체의 수명에 연결하는 RAII다. 생성자는 유효한 자원을 획득해 객체의 불변 조건을 확립하고, 소멸자는 객체의 수명이 끝날 때 자원을 반환한다. 객체가 자동 저장 기간을 가지거나 다른 RAII 객체의 멤버로 소유되면 정상 반환, 조기 반환과 예외 전달을 포함한 모든 범위 종료 경로에서 정리 작업이 수행된다.[59][60]

C++의 자원 관리 구조
  • 자원 관리
    • 저장 기간
      • 정적 저장 기간
      • 스레드 저장 기간
      • 자동 저장 기간
      • 동적 저장 기간
    • 객체 수명
      • 초기화
      • 사용
      • 파괴
    • RAII
      • 생성자에서 자원 획득
      • 객체가 자원 소유
      • 소멸자에서 자원 반환
    • 소유권 타입
      • 값 타입
      • std::unique_ptr
      • std::shared_ptr
      • std::weak_ptr
    • 메모리 할당 구조
      • newdelete
      • 표준 컨테이너
      • 할당자
      • std::pmr::memory_resource

저장 공간과 객체

C++에서 저장 공간과 객체는 같은 개념이 아니다. 저장 공간은 바이트가 배치되는 영역이고, 객체는 특정 타입과 수명을 가지며 그 저장 공간을 점유하는 실행 중 개체다. 하나의 저장 공간은 시간에 따라 서로 다른 객체를 담을 수 있으며, 저장 공간이 존재하더라도 그 안에서 특정 객체의 수명이 시작되지 않았을 수 있다.[61][62]

#include <cstddef>
#include <new>

struct Packet {
    int type;
    int size;
};

int main()
{
    alignas(Packet)
    std::byte storage[sizeof(Packet)];

    // storage 안에서 Packet 객체의 수명을 시작한다.
    Packet* packet =
        new (storage) Packet {
            .type = 1,
            .size = 128
        };

    int size = packet->size;

    // Packet 객체의 수명을 명시적으로 끝낸다.
    packet->~Packet();

    // 바이트 배열 storage 자체는 아직 살아 있다.
    return size;
}

storagestd::byte 배열 객체이고, 배치 new가 같은 저장 공간 안에 Packet 객체를 생성한다. Packet의 소멸자를 호출한 뒤에는 바이트 배열이 남아 있지만 그 안의 Packet 객체는 더 이상 살아 있지 않다.

일반적인 애플리케이션에서는 이런 직접적인 수명 조작보다 표준 컨테이너와 소유권 타입을 사용하는 편이 구조를 명확하게 만든다. 직접 저장 공간을 다루는 기능은 컨테이너, 메모리 풀, 객체 저장소와 런타임 구현 같은 저수준 구성 요소를 만들 때 사용된다.

저장 기간

저장 기간은 객체를 담는 저장 공간이 잠재적으로 얼마나 오래 유지되는지를 나타낸다. C++ 표준은 정적, 스레드, 자동과 동적 저장 기간을 규정한다.[63]

저장 기간대표적인 객체일반적인 유지 범위
정적 저장 기간전역 변수, 이름공간 변수, 정적 지역 변수프로그램 전체
스레드 저장 기간thread_local 변수개별 스레드 전체
자동 저장 기간지역 변수, 함수 매개변수블록 또는 함수 호출
동적 저장 기간new로 생성된 객체, 동적 컨테이너 저장 공간명시적 또는 소유권 기반 반환까지

저장 기간은 저장 공간의 잠재적 지속 시간을 나타내며 객체 수명과 완전히 같은 개념은 아니다. 객체는 저장 공간이 확보된 뒤 초기화를 통해 수명을 시작하고, 소멸이나 저장 공간 재사용으로 수명을 끝낸다.

자동 저장 기간

함수나 블록 안에서 일반적으로 선언한 지역 객체는 자동 저장 기간을 가진다.

#include <string>

void process()
{
    int count = 10;
    std::string name = "C++";

    use(count, name);
}

countname은 선언 지점에 도달하면 초기화되고, 블록을 벗어날 때 선언의 역순으로 파괴된다.

class Trace {
    const char* name_;

public:
    explicit Trace(const char* name)
        : name_(name)
    {
        print("create", name_);
    }

    ~Trace()
    {
        print("destroy", name_);
    }
};

void run()
{
    Trace first("first");
    Trace second("second");
    Trace third("third");
}

일반적인 파괴 순서는 다음과 같다.

create first
create second
create third
destroy third
destroy second
destroy first

나중에 생성된 객체가 먼저 파괴되므로 앞에서 생성된 객체가 뒤에서 생성된 객체의 정리 과정에 필요한 기반 자원을 제공할 수 있다.

중첩 블록을 사용하면 자원이 필요한 범위를 더 짧게 제한할 수 있다.

void update()
{
    prepare_update();

    {
        std::lock_guard lock(state_mutex);

        modify_shared_state();
    }

    // 이 지점에서는 잠금이 이미 해제됐다.
    perform_expensive_work();
}

뮤텍스 잠금은 공유 상태를 변경하는 동안에만 유지된다.

정적 저장 기간

전역 변수, 이름공간 범위 변수, static 데이터 멤버와 정적 지역 변수는 정적 저장 기간을 가질 수 있다.

int global_count = 0;

class Entity {
public:
    inline static unsigned long long next_id = 1;
};

void process()
{
    static int invocation_count = 0;

    ++invocation_count;
}

정적 저장 기간을 가진 객체의 저장 공간은 프로그램 실행 기간 동안 유지된다. 동적 초기화가 필요한 객체의 초기화 시점과 서로 다른 번역 단위 사이의 순서에는 주의가 필요하다.

// logger.cpp
Logger global_logger(create_log_file());

// configuration.cpp
Configuration global_configuration(
    global_logger
);

서로 다른 번역 단위에 있는 동적 초기화 객체의 순서에 의존하면 한 객체가 초기화되기 전에 다른 객체가 접근할 수 있다.

필요한 시점에 초기화되는 함수 지역 정적 객체를 사용해 순서 의존성을 줄일 수 있다.

Logger& global_logger()
{
    static Logger logger(
        create_log_file()
    );

    return logger;
}

함수 지역 정적 객체는 선언 지점이 처음 실행될 때 초기화된다. C++11부터 이러한 초기화는 여러 스레드가 동시에 접근해도 한 번만 수행되도록 규정된다.

void write_log(std::string_view message)
{
    global_logger().write(message);
}

정적 객체의 소멸 순서 역시 다른 정적 객체의 수명에 의존할 수 있다. 프로그램 종료 시점까지 반드시 유지되어야 하고 종료 정리가 중요하지 않은 일부 기반 서비스는 의도적으로 프로세스 수명과 같게 설계하기도 한다. 일반적인 자원은 가능한 한 명확한 상위 소유 객체에 포함시키는 방식이 구조를 더 예측 가능하게 만든다.

스레드 저장 기간

thread_local로 선언한 객체는 스레드마다 별도의 인스턴스를 가진다.

thread_local int current_worker_index = -1;

void worker_main(int index)
{
    current_worker_index = index;

    process_jobs();
}

각 스레드는 자신의 current_worker_index 객체를 사용하므로 다른 스레드의 값과 직접 충돌하지 않는다.

클래스 타입도 사용할 수 있다.

class ScratchBuffer {
    std::vector<std::byte> storage_;

public:
    std::span<std::byte> acquire(
        std::size_t size
    )
    {
        storage_.resize(size);

        return storage_;
    }
};

thread_local ScratchBuffer scratch_buffer;

스레드 지역 객체는 해당 스레드에서 처음 필요한 시점에 초기화될 수 있으며 스레드 종료 과정에서 파괴된다. 스레드 종료 순서와 다른 전역 서비스의 수명에 의존하는 소멸자는 구조를 복잡하게 만들 수 있다.

동적 저장 기간

new 표현식으로 생성한 객체는 동적 저장 기간을 가진다. 객체의 수명은 객체를 만든 블록과 독립적으로 유지될 수 있다.[64]

int* value = new int(42);

use(*value);

delete value;

new int(42)는 일반적으로 다음 두 작업을 결합한다.

  1. int 객체를 담을 저장 공간을 확보한다.
  2. 확보된 저장 공간에서 int 객체를 초기화한다.

delete value는 반대 순서의 작업을 수행한다.

  1. 가리키는 객체를 파괴한다.
  2. 객체가 사용하던 저장 공간을 반환한다.

클래스 객체에서는 생성자와 소멸자가 실행된다.

class Device {
public:
    Device()
    {
        initialize_device();
    }

    ~Device()
    {
        shutdown_device();
    }
};

Device* device = new Device();

device->run();

delete device;

생성자가 예외를 던지면 new 표현식은 확보했던 저장 공간을 대응하는 해제 함수로 반환할 수 있다. 객체의 수명은 생성자가 정상적으로 완료된 뒤에 시작한다.

newdelete

newdelete는 C++ 객체 모델에 맞춰 저장 공간의 할당과 객체의 생성·파괴를 연결하는 표현식이다.

단일 객체

Widget* widget =
    new Widget(1280, 720);

delete widget;

new로 단일 객체를 생성했다면 단일 객체용 delete를 사용해야 한다.

배열

Widget* widgets =
    new Widget[10];

delete[] widgets;

new[]로 만든 배열은 delete[]로 반환해야 한다. deletedelete[]를 서로 바꿔 사용하면 동작이 정의되지 않는다.

배열의 원소는 앞에서 뒤로 생성되고 뒤에서 앞으로 파괴된다.

class Trace {
public:
    Trace()
    {
        print("create");
    }

    ~Trace()
    {
        print("destroy");
    }
};

Trace* objects = new Trace[3];

delete[] objects;

동적 배열은 일반적으로 std::vectorstd::make_unique<T[]>로 표현할 수 있다.

#include <vector>

std::vector<Widget> widgets(10);

벡터는 원소 개수와 저장 공간의 수명을 함께 관리하며 예외가 발생해도 이미 생성된 원소를 정리한다.

초기화 방식

int* uninitialized = new int;
int* zero_initialized = new int();
int* also_zero_initialized = new int {};

Widget* default_widget = new Widget;
Widget* configured_widget =
    new Widget(1280, 720);

기본 타입에서 new int는 값이 정해지지 않은 int 객체를 만들 수 있고, new int()new int {}는 값을 0으로 초기화한다.

초기화되지 않은 값을 읽으면 잘못된 동작으로 이어질 수 있으므로 객체를 생성할 때 유효한 초기 상태를 확립하는 구조가 중요하다.

할당 실패

일반적인 new 표현식이 저장 공간을 확보하지 못하면 std::bad_alloc 예외를 던진다.

#include <new>

try {
    auto* data =
        new std::byte[very_large_size];

    use(data);

    delete[] data;
}
catch (const std::bad_alloc&) {
    report_out_of_memory();
}

예외를 던지지 않는 std::nothrow 형식도 존재한다.

#include <new>

Widget* widget =
    new (std::nothrow) Widget();

if (!widget) {
    report_allocation_failure();

    return;
}

delete widget;

일반적인 코드에서는 각 할당 지점에서 실패를 개별 처리하기보다 애플리케이션 전체의 메모리 부족 정책과 예외 경계를 설계할 수 있다.

배치 new

배치 new는 새 저장 공간을 할당하지 않고 전달된 주소에서 객체의 수명을 시작한다.

#include <memory>
#include <new>

alignas(Widget)
std::byte storage[sizeof(Widget)];

Widget* widget =
    std::construct_at(
        reinterpret_cast<Widget*>(storage),
        1280,
        720
    );

widget->draw();

std::destroy_at(widget);

현대 C++에서는 직접 배치 new와 소멸자 호출을 작성하는 대신 std::construct_atstd::destroy_at을 사용할 수 있다. 저장 공간 자체의 확보와 반환은 별도로 관리해야 한다.

이 기능은 컨테이너가 용량을 미리 확보한 뒤 필요한 위치에서만 원소 객체의 수명을 시작할 때 사용된다.

C의 메모리 할당 함수

C++에서도 <cstdlib>std::malloc, std::calloc, std::reallocstd::free를 사용할 수 있다. 이 함수들은 바이트 저장 공간을 관리하지만 일반적인 클래스 객체의 생성자와 소멸자를 자동으로 호출하지 않는다.[65]

#include <cstdlib>

void* storage =
    std::malloc(1024);

if (!storage) {
    handle_failure();

    return;
}

use_raw_storage(storage, 1024);

std::free(storage);

클래스 객체를 malloc으로 확보한 메모리에 단순 대입하는 것만으로는 정상적인 객체 생성이 되지 않는다.

class Device {
public:
    Device();
    ~Device();
};

void* storage =
    std::malloc(sizeof(Device));

// Device 생성자는 호출되지 않았다.
// Device* device = static_cast<Device*>(storage);
// device->run();

저장 공간 안에 객체를 생성하려면 별도로 객체 수명을 시작해야 한다.

#include <cstdlib>
#include <memory>

void* storage =
    std::malloc(sizeof(Device));

if (!storage) {
    throw std::bad_alloc();
}

Device* device = nullptr;

try {
    device = std::construct_at(
        static_cast<Device*>(storage)
    );

    device->run();

    std::destroy_at(device);
    std::free(storage);
}
catch (...) {
    if (device) {
        std::destroy_at(device);
    }

    std::free(storage);

    throw;
}

직접 처리하면 생성 실패와 모든 반환 경로를 관리해야 하므로, 저수준 할당기 구현이나 외부 C API와의 연동이 아니라면 RAII 기반 타입과 표준 컨테이너를 사용할 수 있다.

직접 동적 할당의 문제

원시 포인터로 직접 소유권을 관리하면 할당과 해제 사이의 모든 실행 경로를 사람이 추적해야 한다.

메모리 누수

void process()
{
    Widget* widget = new Widget();

    if (!initialize()) {
        return;
    }

    widget->run();

    delete widget;
}

initialize()가 실패하면 함수가 먼저 반환되어 widget이 해제되지 않는다.

예외도 같은 문제를 만들 수 있다.

void process()
{
    Widget* widget = new Widget();

    perform_operation();

    delete widget;
}

perform_operation()이 예외를 던지면 delete에 도달하지 않는다.

이중 해제

Widget* widget = new Widget();

delete widget;
delete widget;

같은 동적 객체를 두 번 파괴하고 해제하면 동작이 정의되지 않는다.

포인터를 nullptr로 바꾸는 방식은 하나의 포인터 변수에서 반복 해제를 피할 수 있지만, 동일 객체를 가리키는 다른 포인터까지 해결하지는 않는다.

Widget* first = new Widget();
Widget* second = first;

delete first;
first = nullptr;

// second는 이미 파괴된 객체를 가리킨다.
// delete second;

댕글링 포인터

Widget* widget = new Widget();

delete widget;

// widget->draw();
// 이미 수명이 끝난 객체에 접근한다.

저장 공간이 나중에 다른 객체에 재사용될 수 있으므로 파괴된 객체의 주소를 사용하는 결과는 예측할 수 없다.

잘못된 해제 함수

Widget* widgets = new Widget[10];

// delete widgets;
// new[]와 맞지 않는 해제 방식

delete[] widgets;

malloc으로 확보한 저장 공간은 free, new로 만든 객체는 대응하는 delete를 사용해야 한다.

소유권 불명확성

Widget* create_widget();

void register_widget(Widget* widget);
void process_widget(Widget* widget);

함수 선언만으로는 반환된 포인터를 누가 해제하는지, register_widget이 소유권을 가져가는지, process_widget이 포인터를 보관하는지 알기 어렵다.

소유권을 타입에 드러낼 수 있다.

std::unique_ptr<Widget> create_widget();

void register_widget(
    std::unique_ptr<Widget> widget
);

void process_widget(
    Widget& widget
);

반환형과 매개변수 타입이 자원의 이동 여부를 나타낸다.

RAII

RAII는 자원의 획득을 객체 초기화와 결합하고 반환을 소멸자와 결합하는 자원 관리 방식이다. 이름은 “Resource Acquisition Is Initialization”의 약자지만, 실제 핵심은 자원 수명을 객체와 범위의 수명에 묶는 것이다.

class File {
    NativeFileHandle handle_;

public:
    explicit File(
        const std::filesystem::path& path
    )
        : handle_(open_native_file(path))
    {
        if (!is_valid(handle_)) {
            throw FileError(path);
        }
    }

    ~File()
    {
        close_native_file(handle_);
    }

    File(const File&) = delete;
    File& operator=(const File&) = delete;

    File(File&& other) noexcept
        : handle_(
              std::exchange(
                  other.handle_,
                  invalid_file_handle()
              )
          )
    {
    }

    File& operator=(File&& other) noexcept
    {
        if (this == &other) {
            return *this;
        }

        close_native_file(handle_);

        handle_ = std::exchange(
            other.handle_,
            invalid_file_handle()
        );

        return *this;
    }

    void write(
        std::span<const std::byte> data
    )
    {
        write_native_file(
            handle_,
            data
        );
    }
};

이 타입은 다음 규칙을 표현한다.

  • 생성에 성공한 File 객체는 유효한 파일 핸들을 소유한다.
  • 복사가 금지되어 하나의 핸들을 여러 객체가 중복 해제하지 않는다.
  • 이동을 통해 소유권을 다른 객체로 전달할 수 있다.
  • 소멸자가 핸들을 정확히 한 번 닫는다.
  • 사용자는 파일 닫기 함수를 직접 호출할 필요가 없다.
void save_document(
    std::span<const std::byte> data
)
{
    File file("document.bin");

    validate(data);

    file.write(data);
}

validatewrite에서 예외가 발생해도 스택 풀기 과정에서 file의 소멸자가 호출된다.

RAII 객체의 자원 수명
  1. 객체를 위한 저장 공간 확보
  2. 생성자에서 자원 획득
  3. 유효한 객체 수명 시작
  4. 멤버 함수를 통해 자원 사용
  5. 범위 종료 또는 소유 객체 파괴
  6. 소멸자에서 자원 반환
  7. 객체 수명 종료

RAII와 부분 생성

클래스 생성 중 하나의 멤버 생성자가 예외를 던지면 이미 생성이 완료된 기반 클래스와 멤버 객체는 자동으로 파괴된다. 생성이 완료되지 않은 최종 객체 자체의 소멸자는 호출되지 않는다.

class Server {
    Socket socket_;
    LogFile log_;
    WorkerPool workers_;

public:
    Server(
        const Address& address,
        const Path& log_path
    )
        : socket_(address),
          log_(log_path),
          workers_(8)
    {
    }
};

생성 순서는 멤버 선언 순서에 따른다.

  1. socket_
  2. log_
  3. workers_
  4. Server 생성자 본문

workers_ 생성 중 예외가 발생하면 먼저 생성된 log_socket_가 역순으로 파괴된다. 각 멤버가 자신의 자원을 RAII로 관리하면 Server 생성자에서 수동 정리 코드를 작성할 필요가 없다.

class UnsafeServer {
    NativeSocket socket_;
    NativeLogHandle log_;

public:
    UnsafeServer()
    {
        socket_ = open_socket();

        log_ = open_log();

        // open_log가 실패하거나 이후 코드가 예외를 던지면
        // socket_을 직접 정리해야 하는 경로가 생긴다.
    }
};

원시 핸들 대신 소유 타입을 멤버로 사용하면 부분 생성 문제를 각 타입의 소멸자가 처리한다.

범위 기반 자원 관리

RAII는 메모리에만 적용되지 않는다.

뮤텍스 잠금

#include <mutex>

std::mutex state_mutex;
State state;

void update_state(
    const StateChange& change
)
{
    std::lock_guard lock(state_mutex);

    apply_change(state, change);
}

std::lock_guard는 생성자에서 뮤텍스를 잠그고 소멸자에서 잠금을 해제한다.[66]

조기 반환에도 안전하다.

bool update_if_valid(
    const StateChange& change
)
{
    std::lock_guard lock(state_mutex);

    if (!is_valid(change)) {
        return false;
    }

    apply_change(state, change);

    return true;
}

변경 가능한 잠금

#include <mutex>

void wait_for_data()
{
    std::unique_lock lock(data_mutex);

    data_condition.wait(
        lock,
        [] {
            return data_ready;
        }
    );

    process_data();

    lock.unlock();

    perform_unlocked_work();
}

std::unique_lock은 잠금의 지연 획득, 명시적 해제, 이동과 조건 변수 연동 등을 지원한다.

트랜잭션

class Transaction {
    Database& database_;
    bool committed_ = false;

public:
    explicit Transaction(Database& database)
        : database_(database)
    {
        database_.begin();
    }

    ~Transaction()
    {
        if (!committed_) {
            database_.rollback();
        }
    }

    void commit()
    {
        database_.commit();
        committed_ = true;
    }
};
void transfer(
    Database& database,
    AccountId from,
    AccountId to,
    Money amount
)
{
    Transaction transaction(database);

    database.withdraw(from, amount);
    database.deposit(to, amount);

    transaction.commit();
}

중간 작업이 실패하면 소멸자가 롤백한다.

범위 종료 동작

C++23은 <scope>에 범위 종료 시 함수를 호출하는 범위 보호 타입을 제공한다.

#include <scope>

void update_native_state()
{
    begin_native_update();

    auto cleanup =
        std::scope_exit([] {
            end_native_update();
        });

    perform_update();
}

전용 클래스를 만들기에는 너무 작은 일회성 정리 동작을 범위에 연결할 수 있다.

0의 규칙

자원을 직접 관리하지 않고 RAII 멤버 타입을 조합하는 클래스는 소멸자, 복사 생성자, 이동 생성자와 대입 연산자를 직접 작성하지 않아도 된다. 이를 0의 규칙이라고 한다.

#include <string>
#include <vector>

class Document {
    std::string title_;
    std::vector<std::string> lines_;

public:
    Document(
        std::string title,
        std::vector<std::string> lines
    )
        : title_(std::move(title)),
          lines_(std::move(lines))
    {
    }
};

std::stringstd::vector가 자신의 저장 공간을 관리하므로 Document는 특별 멤버 함수를 직접 구현하지 않아도 올바르게 복사, 이동과 파괴된다.

Document first(
    "C++",
    {"one", "two"}
);

Document second = first;
Document third = std::move(first);

직접 자원을 관리하는 최하위 타입에서만 필요한 소유권 규칙을 구현하고, 상위 타입은 그러한 타입을 멤버로 조합하는 구조를 사용할 수 있다.

3의 규칙과 5의 규칙

C++11 이전에는 클래스가 소멸자, 복사 생성자 또는 복사 대입 연산자 가운데 하나를 직접 정의해야 한다면 나머지도 함께 검토해야 한다는 3의 규칙이 사용됐다.

class Buffer {
    std::size_t size_;
    std::byte* data_;

public:
    explicit Buffer(std::size_t size)
        : size_(size),
          data_(new std::byte[size])
    {
    }

    ~Buffer()
    {
        delete[] data_;
    }

    Buffer(const Buffer& other)
        : size_(other.size_),
          data_(new std::byte[other.size_])
    {
        std::copy_n(
            other.data_,
            size_,
            data_
        );
    }

    Buffer& operator=(
        const Buffer& other
    )
    {
        if (this == &other) {
            return *this;
        }

        Buffer copy(other);

        swap(copy);

        return *this;
    }

    void swap(Buffer& other) noexcept
    {
        using std::swap;

        swap(size_, other.size_);
        swap(data_, other.data_);
    }
};

C++11 이후 이동 생성자와 이동 대입 연산자가 추가되면서 5의 규칙으로 확장된다.

Buffer(Buffer&& other) noexcept
    : size_(
          std::exchange(
              other.size_,
              0
          )
      ),
      data_(
          std::exchange(
              other.data_,
              nullptr
          )
      )
{
}

Buffer& operator=(Buffer&& other) noexcept
{
    if (this == &other) {
        return *this;
    }

    delete[] data_;

    size_ = std::exchange(
        other.size_,
        0
    );

    data_ = std::exchange(
        other.data_,
        nullptr
    );

    return *this;
}

실제 코드에서는 이 직접 구현 대신 std::vector<std::byte>를 멤버로 사용해 0의 규칙으로 전환할 수 있다.

class Buffer {
    std::vector<std::byte> data_;

public:
    explicit Buffer(std::size_t size)
        : data_(size)
    {
    }

    std::span<std::byte> data() noexcept
    {
        return data_;
    }
};

소유권

소유권은 자원의 수명을 끝낼 책임이 어느 객체나 코드 영역에 있는지를 나타낸다. C++ 표준이 모든 원시 포인터에 별도의 소유권 의미를 자동 부여하는 것은 아니지만, 값 타입과 스마트 포인터, 참조와 뷰를 사용해 소유 관계를 인터페이스에 표현할 수 있다.

C++의 일반적인 소유 관계
  • 자원 접근
    • 직접 소유
      • 값 객체
      • 컨테이너
      • std::unique_ptr
      • std::shared_ptr
    • 소유권 관찰
      • std::weak_ptr
    • 비소유 접근
      • 참조
      • 원시 포인터
      • 반복자
      • std::span
      • std::string_view

값에 의한 소유

class Scene {
    Camera camera_;
    std::vector<Entity> entities_;
    PhysicsWorld physics_;
};

Scene 객체는 멤버를 직접 소유한다. Scene이 파괴되면 멤버도 자동으로 파괴된다.

값 소유는 별도의 동적 할당과 간접 접근이 필요하지 않으며, 소유 관계가 타입 구조에 직접 나타난다.

비소유 참조

void draw_scene(
    const Scene& scene,
    Renderer& renderer
)
{
}

함수는 scenerenderer를 소유하지 않는다. 호출이 끝난 뒤에도 원래 객체의 수명은 호출자가 관리한다.

선택적으로 대상이 없을 수 있음을 표현하려면 포인터를 사용할 수 있다.

void set_parent(Entity* parent)
{
    parent_ = parent;
}

포인터를 멤버에 보관한다면 가리키는 객체가 포인터보다 오래 살아야 한다는 수명 계약이 필요하다.

비소유 뷰

#include <span>

void upload(
    std::span<const std::byte> data
)
{
}

std::span은 연속된 원소 범위를 소유하지 않고 관찰한다. 원본 저장 공간은 span이 사용되는 동안 살아 있어야 한다.

#include <string_view>

void log_message(
    std::string_view message
)
{
}

std::string_view도 문자 저장 공간을 소유하지 않는다.

std::string_view invalid_view()
{
    std::string text = "C++";

    return text;
}

함수 종료 시 text가 파괴되므로 반환된 뷰는 유효한 저장 공간을 가리키지 않는다.

std::unique_ptr

std::unique_ptr는 하나의 동적 객체나 배열에 대한 단독 소유권을 표현한다. 복사할 수 없고 이동을 통해 소유권을 이전할 수 있다.

#include <memory>

auto widget =
    std::make_unique<Widget>(
        1280,
        720
    );

widget->draw();

widget이 범위를 벗어나면 Widget 객체가 자동으로 파괴된다.

void process()
{
    auto widget =
        std::make_unique<Widget>();

    if (!initialize()) {
        return;
    }

    widget->run();
}

조기 반환에도 누수가 발생하지 않는다.

소유권 이동

std::unique_ptr<Widget>
create_widget()
{
    return std::make_unique<Widget>();
}

void register_widget(
    std::unique_ptr<Widget> widget
)
{
    widgets_.push_back(
        std::move(widget)
    );
}
auto widget = create_widget();

register_widget(
    std::move(widget)
);

// widget은 이제 일반적으로 nullptr다.

함수의 반환형과 매개변수 타입이 소유권 이동을 나타낸다.

비소유 접근

void draw(Widget& widget)
{
    widget.draw();
}

auto widget =
    std::make_unique<Widget>();

draw(*widget);

소유권을 넘기지 않는 함수에는 unique_ptr 자체보다 객체의 참조를 전달할 수 있다.

대상이 선택적이라면 원시 포인터를 비소유 관찰자로 전달할 수 있다.

void set_debug_target(
    Widget* widget
)
{
}

set_debug_target(
    widget.get()
);

배열

auto values =
    std::make_unique<int[]>(100);

values[0] = 42;

크기와 반복 기능이 필요한 일반적인 동적 배열에는 std::vector가 더 많은 기능을 제공한다.

사용자 정의 삭제자

unique_ptrdelete 외의 반환 함수가 필요한 자원도 관리할 수 있다.

#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* file) const noexcept
    {
        if (file) {
            std::fclose(file);
        }
    }
};

using FilePointer =
    std::unique_ptr<
        std::FILE,
        FileCloser
    >;

FilePointer open_file(
    const char* path
)
{
    return FilePointer(
        std::fopen(path, "rb")
    );
}
FilePointer file =
    open_file("data.bin");

if (!file) {
    throw FileError("data.bin");
}

FilePointer가 파괴되면 FileCloserstd::fclose를 호출한다.

운영체제 핸들도 같은 방식으로 관리할 수 있다.

struct WindowDeleter {
    void operator()(
        NativeWindow* window
    ) const noexcept
    {
        if (window) {
            destroy_native_window(window);
        }
    }
};

using NativeWindowOwner =
    std::unique_ptr<
        NativeWindow,
        WindowDeleter
    >;

std::shared_ptr

std::shared_ptr는 같은 동적 객체를 여러 소유자가 공동으로 소유하는 구조를 제공한다. 마지막 소유 shared_ptr가 파괴되거나 다른 대상을 가리키게 되면 관리 객체가 파괴된다.

#include <memory>

auto texture =
    std::make_shared<Texture>(
        "surface.png"
    );

auto material_texture = texture;
auto preview_texture = texture;

세 스마트 포인터는 같은 Texture 객체의 소유권을 공유한다.

material_texture.reset();
preview_texture.reset();

// texture가 여전히 소유하므로 객체는 살아 있다.

texture.reset();

// 마지막 소유자가 사라져 객체가 파괴된다.

공동 소유가 실제 도메인 관계를 나타낼 때 사용할 수 있다.

  • 여러 비동기 작업이 같은 상태를 독립적으로 유지해야 하는 경우
  • 객체 수명이 특정 단일 부모보다 여러 소비자의 수명에 의해 결정되는 경우
  • 캐시와 외부 사용자 모두가 일시적으로 객체를 소유하는 경우
  • 콜백이 호출될 때까지 상태를 보존해야 하는 경우

단순히 포인터 전달을 편하게 만들기 위해 모든 객체를 shared_ptr로 관리하면 객체의 실제 소유자가 불명확해질 수 있다.

std::make_shared

auto object =
    std::make_shared<LargeObject>(
        argument
    );

std::make_shared는 일반적으로 관리 객체와 제어 블록을 하나의 할당으로 구성할 수 있다. 반면 원시 포인터에서 직접 shared_ptr를 만들면 객체와 제어 블록이 별도로 할당될 수 있다.

std::shared_ptr<LargeObject> object(
    new LargeObject(argument)
);

생성 과정의 명확성과 할당 효율을 위해 std::make_shared를 사용할 수 있다. 다만 사용자 정의 삭제자, 별도 메모리 수명이나 특수한 할당 전략이 필요할 때는 직접 생성 형식이 사용될 수 있다.

제어 블록

shared_ptr는 일반적으로 다음 정보를 가진 제어 블록을 사용한다.

  • 강한 소유 참조 수
  • 약한 관찰 참조 수
  • 관리 객체를 파괴하는 삭제자
  • 필요한 경우 할당자 정보

shared_ptr 객체 자체를 복사하면 제어 블록의 소유 참조 수가 증가한다.

auto first =
    std::make_shared<Widget>();

auto second = first;
auto third = second;

참조 계수 변경에는 원자적 연산이 사용될 수 있으므로 단독 소유권만 필요한 경우 unique_ptr보다 추가 비용이 발생할 수 있다.

shared_ptr의 서로 다른 인스턴스를 여러 스레드가 각각 사용하는 것은 지원되지만, 같은 shared_ptr 객체 자체를 동기화 없이 동시에 수정하는 문제와 관리 대상 객체의 스레드 안전성은 별도로 다뤄야 한다.

순환 소유와 std::weak_ptr

서로가 shared_ptr로 상대 객체를 소유하면 참조 계수가 영원히 0이 되지 않는 순환 소유가 발생할 수 있다.

class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> previous;
};

auto first =
    std::make_shared<Node>();

auto second =
    std::make_shared<Node>();

first->next = second;
second->previous = first;

외부의 firstsecond가 사라져도 두 객체가 서로를 소유하므로 파괴되지 않는다.

한쪽 관계가 소유권이 아니라 관찰 관계라면 std::weak_ptr를 사용할 수 있다.

class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> previous;
};

weak_ptr는 객체를 소유하지 않고 shared_ptr 제어 블록을 관찰한다.

void use_parent(
    const std::weak_ptr<Node>& parent
)
{
    if (auto object = parent.lock()) {
        object->process();
    }
}

lock()은 객체가 아직 살아 있으면 새 shared_ptr를 반환하고, 이미 파괴됐다면 빈 shared_ptr를 반환한다.

std::enable_shared_from_this

공동 소유되는 객체가 자신의 멤버 함수에서 같은 제어 블록을 공유하는 shared_ptr를 만들어야 할 때 std::enable_shared_from_this를 사용할 수 있다.[67]

#include <memory>

class Connection :
    public std::enable_shared_from_this<
        Connection
    > {
public:
    void start_async_read()
    {
        auto self =
            shared_from_this();

        async_read(
            [self](
                ReadResult result
            ) {
                self->handle(result);
            }
        );
    }

private:
    void handle(
        const ReadResult& result
    )
    {
    }
};

콜백이 끝날 때까지 self가 연결 객체의 수명을 유지한다.

객체가 실제로 shared_ptr에 의해 관리되지 않는 상태에서 shared_from_this()를 호출하면 올바르게 사용할 수 없다.

auto connection =
    std::make_shared<Connection>();

connection->start_async_read();

같은 원시 포인터로 독립된 여러 shared_ptr를 만들면 서로 다른 제어 블록이 생성되어 이중 해제가 발생할 수 있다.

Widget* raw = new Widget();

// 서로 다른 제어 블록이 같은 raw를 소유한다.
// std::shared_ptr<Widget> first(raw);
// std::shared_ptr<Widget> second(raw);

처음부터 하나의 make_shared 결과를 복사해 공유해야 한다.

컨테이너를 통한 메모리 관리

일반적인 C++ 프로그램에서 동적 저장 공간은 직접 new로 관리하기보다 표준 컨테이너가 소유한다.

std::vector

#include <vector>

std::vector<int> values;

values.push_back(10);
values.push_back(20);
values.push_back(30);

std::vector는 다음 작업을 관리한다.

  • 연속된 원소 저장 공간 확보
  • 필요한 위치에서 원소 생성
  • 용량 증가 시 새 저장 공간 확보
  • 원소 이동 또는 복사
  • 이전 원소 파괴
  • 이전 저장 공간 반환
  • 벡터 파괴 시 모든 원소와 저장 공간 정리
std::vector<Widget> widgets;

widgets.reserve(100);

for (int index = 0;
     index < 100;
     ++index) {
    widgets.emplace_back(index);
}

reserve는 예상 원소 수에 맞는 용량을 미리 확보해 반복적인 재할당을 줄일 수 있다.

포인터 무효화

컨테이너가 저장 공간을 재할당하면 기존 원소를 가리키던 포인터, 참조와 반복자가 무효화될 수 있다.

std::vector<int> values;

values.push_back(10);

int* first = &values[0];

values.push_back(20);
values.push_back(30);

// 재할당이 발생했다면 first는 더 이상 유효하지 않다.

미리 충분한 용량을 예약하거나, 안정된 주소가 필요한 자료구조를 선택하거나, 인덱스와 핸들을 사용해 객체를 간접 식별할 수 있다.

std::array

#include <array>

std::array<int, 4> values {
    10,
    20,
    30,
    40
};

크기가 컴파일 시점에 고정된 배열을 값 타입으로 관리한다. 별도의 동적 할당이 필요하지 않으며 복사와 반복자 인터페이스를 제공한다.

노드 기반 컨테이너

#include <list>

std::list<Widget> widgets;

노드 기반 컨테이너는 각 원소를 별도 노드에 저장할 수 있다. 삽입 시 기존 원소의 주소 안정성이 좋을 수 있지만, 원소마다 할당 비용과 포인터 간접 접근이 발생할 수 있다.

자료구조 선택은 단순한 기능 목록뿐 아니라 다음 특성을 함께 고려한다.

  • 원소 개수와 증가 방식
  • 메모리 연속성
  • 캐시 지역성
  • 삽입과 삭제 패턴
  • 포인터와 반복자 안정성
  • 할당 횟수
  • 원소 이동 가능성

할당자

할당자는 표준 컨테이너가 원소를 저장할 메모리를 확보하고 반환하는 방식을 매개변수화한다. 표준 컨테이너는 기본적으로 std::allocator<T>를 사용한다.

#include <memory>
#include <vector>

std::vector<
    int,
    std::allocator<int>
> values;

대부분의 애플리케이션은 기본 할당자를 직접 다룰 필요가 없다. 할당자는 메모리 풀, 공유 메모리, 특정 장치 메모리, 추적 할당기와 같은 별도 저장 전략을 컨테이너에 연결할 때 사용된다.

할당과 객체 생성의 분리

저수준 할당자는 일반적으로 저장 공간 확보와 객체 생성을 분리한다.

#include <memory>

std::allocator<Widget> allocator;

Widget* storage =
    allocator.allocate(10);

std::construct_at(
    storage,
    1280,
    720
);

std::destroy_at(storage);

allocator.deallocate(
    storage,
    10
);

allocate(10)Widget 열 개를 담을 수 있는 저장 공간을 확보하지만 열 개의 Widget 객체를 자동 생성하지 않는다. 객체 수명은 construct_at으로 별도로 시작한다.

여러 객체를 생성하는 도중 예외가 발생하면 이미 생성된 객체만 역순으로 파괴하고 저장 공간을 반환해야 한다. 표준 컨테이너는 이러한 과정을 내부적으로 관리한다.

다형적 메모리 자원

C++17의 <memory_resource>는 컨테이너의 타입을 메모리 자원 구현과 직접 결합하지 않고, 실행 시간에 메모리 할당 전략을 선택할 수 있는 std::pmr 구조를 제공한다.[68]

#include <memory_resource>
#include <vector>

std::pmr::vector<int> values;

std::pmr::vector<int>는 다형적 할당자를 사용하며 연결된 memory_resource에 저장 공간을 요청한다.

단조 메모리 자원

std::pmr::monotonic_buffer_resource는 개별 해제 없이 메모리를 순차적으로 소비하고 자원 전체가 파괴될 때 한꺼번에 반환하는 구조다.

#include <array>
#include <cstddef>
#include <memory_resource>
#include <string>
#include <vector>

std::array<std::byte, 4096> storage;

std::pmr::monotonic_buffer_resource resource(
    storage.data(),
    storage.size()
);

std::pmr::vector<
    std::pmr::string
> names(&resource);

names.emplace_back("C++");
names.emplace_back("Wave");
names.emplace_back("Rust");

names의 저장 공간과 문자열 내부 저장 공간이 같은 메모리 자원을 사용할 수 있다.

이 구조는 다음과 같은 수명 패턴에 적합하다.

  • 프레임 단위 임시 데이터
  • 파서의 구문 트리
  • 요청 단위 서버 데이터
  • 한 번에 만들어 한 번에 파괴하는 객체 집합
  • 컴파일 작업 단위의 중간 표현
void render_frame()
{
    std::array<std::byte, 64 * 1024>
        frame_storage;

    std::pmr::monotonic_buffer_resource
        frame_resource(
            frame_storage.data(),
            frame_storage.size()
        );

    std::pmr::vector<RenderCommand>
        commands(&frame_resource);

    build_commands(commands);
    submit(commands);

    // 함수 종료 시 프레임 자원이 한 번에 반환된다.
}

풀 자원

std::pmr::unsynchronized_pool_resourcestd::pmr::synchronized_pool_resource는 비슷한 크기의 작은 할당을 풀로 관리한다.

#include <memory_resource>

std::pmr::unsynchronized_pool_resource
    pool;

std::pmr::vector<SmallObject>
    objects(&pool);

unsynchronized_pool_resource는 내부 동기화를 제공하지 않으므로 하나의 스레드나 외부 동기화 환경에서 사용할 수 있다. 여러 스레드가 직접 공유해야 한다면 동기화된 풀 자원을 선택할 수 있다.

사용자 정의 메모리 자원

#include <memory_resource>

class TrackingResource final :
    public std::pmr::memory_resource {
    std::pmr::memory_resource* upstream_;
    std::size_t allocated_ = 0;

protected:
    void* do_allocate(
        std::size_t bytes,
        std::size_t alignment
    ) override
    {
        void* memory =
            upstream_->allocate(
                bytes,
                alignment
            );

        allocated_ += bytes;

        return memory;
    }

    void do_deallocate(
        void* memory,
        std::size_t bytes,
        std::size_t alignment
    ) override
    {
        allocated_ -= bytes;

        upstream_->deallocate(
            memory,
            bytes,
            alignment
        );
    }

    bool do_is_equal(
        const std::pmr::memory_resource& other
    ) const noexcept override
    {
        return this == &other;
    }

public:
    explicit TrackingResource(
        std::pmr::memory_resource* upstream =
            std::pmr::get_default_resource()
    )
        : upstream_(upstream)
    {
    }

    std::size_t allocated() const noexcept
    {
        return allocated_;
    }
};
TrackingResource tracker;

std::pmr::vector<int>
    values(&tracker);

values.resize(1000);

print(tracker.allocated());

메모리 사용량 추적, 태그 기반 예산, 디버그 로그와 특정 플랫폼 메모리 영역 연결 등에 사용할 수 있다.

클래스별 할당 함수

클래스는 자체 operator newoperator delete를 정의할 수 있다.

class Particle {
public:
    static void* operator new(
        std::size_t size
    )
    {
        return particle_pool.allocate(size);
    }

    static void operator delete(
        void* memory
    ) noexcept
    {
        particle_pool.deallocate(memory);
    }
};
Particle* particle =
    new Particle();

delete particle;

이 경우 Particlenew 표현식은 클래스별 할당 함수를 사용할 수 있다.

할당 함수는 저장 공간을 관리하고 생성자는 객체를 초기화한다. 두 역할을 분리해야 한다.

void* memory =
    Particle::operator new(
        sizeof(Particle)
    );

// 이 시점에는 아직 Particle 객체가 없다.

Particle* particle =
    std::construct_at(
        static_cast<Particle*>(memory)
    );

클래스별 할당은 객체 풀이 타입에 강하게 결합되는 구조를 만든다. 여러 타입과 컨테이너에 같은 전략을 적용하려면 할당자나 std::pmr가 더 유연한 경계를 제공할 수 있다.

정렬

객체는 타입이 요구하는 정렬 조건에 맞는 주소에서 생성되어야 한다.

struct alignas(64) CacheLineData {
    std::atomic<unsigned long long> value;
};

static_assert(
    alignof(CacheLineData) == 64
);

동적 할당은 생성할 타입의 정렬 요구 사항을 만족해야 한다.

CacheLineData* data =
    new CacheLineData();

delete data;

직접 바이트 버퍼를 사용할 때는 alignas를 지정할 수 있다.

alignas(CacheLineData)
std::byte storage[
    sizeof(CacheLineData)
];

임의 주소를 객체 포인터로 변환해도 정렬이 맞지 않으면 올바르게 사용할 수 없다.

std::array<std::byte, 128> bytes;

// bytes.data() + 1은 CacheLineData에 필요한
// 정렬을 만족하지 않을 수 있다.

메모리 풀과 할당자를 구현할 때는 요청 크기뿐 아니라 정렬 값도 함께 처리해야 한다.

객체 표현과 바이트 접근

C++는 객체의 저장 표현을 char, unsigned char 또는 std::byte 계열을 통해 바이트 단위로 관찰할 수 있는 기능을 제공한다.

#include <cstddef>
#include <span>

struct Header {
    int type;
    int size;
};

Header header {
    .type = 1,
    .size = 128
};

std::span<const Header> object(&header, 1);

std::span<const std::byte> bytes =
    std::as_bytes(object);

이 바이트 표현을 그대로 파일이나 네트워크 프로토콜에 저장할 수 있는지는 타입의 패딩, 바이트 순서, 정렬, 표준 레이아웃 여부와 외부 형식의 규칙에 따라 달라진다.

struct Packet {
    std::uint16_t type;
    std::uint32_t size;
};

// sizeof(Packet)은 멤버 크기의 단순 합보다
// 클 수 있으며 대상 플랫폼에 따라 달라질 수 있다.

외부 바이너리 형식에서는 각 필드를 명시적인 폭과 바이트 순서로 직렬화할 수 있다.

void write_packet(
    Writer& writer,
    const Packet& packet
)
{
    writer.write_u16_le(packet.type);
    writer.write_u32_le(packet.size);
}

메모리 안전성

메모리 안전성은 프로그램이 살아 있는 객체와 허용된 저장 범위에만 접근하고, 올바른 타입과 동기화 규칙에 따라 메모리를 사용하는 성질을 뜻한다.

C++의 타입 체계와 표준 라이브러리는 안전한 구조를 만들 수 있는 도구를 제공하지만, 원시 포인터 산술, 직접 객체 수명 관리와 임의 캐스트도 허용하므로 프로그램의 설계가 수명과 소유권을 명확하게 유지해야 한다.

주요 메모리 오류는 다음과 같다.

  • 배열 범위를 벗어난 접근
  • 수명이 끝난 객체 사용
  • 초기화되지 않은 값 읽기
  • 널 포인터 역참조
  • 이중 해제
  • 잘못된 해제 함수 사용
  • 객체 타입과 맞지 않는 접근
  • 정렬되지 않은 주소 사용
  • 동기화 없는 데이터 경쟁
  • 유효하지 않은 반복자와 뷰 사용

범위 초과 접근

int values[3] {
    10,
    20,
    30
};

// int value = values[3];
// 유효한 인덱스는 0, 1, 2다.

std::vector::at는 범위를 검사하고 실패하면 예외를 던진다.

std::vector<int> values {
    10,
    20,
    30
};

int value = values.at(index);

성능이 중요한 내부 반복에서는 미리 범위를 검증한 뒤 operator[]를 사용할 수 있다.

if (index >= values.size()) {
    return std::unexpected(
        Error::InvalidIndex
    );
}

int value = values[index];

사용 후 해제

Widget* widget = new Widget();

delete widget;

// widget->draw();

소유권을 unique_ptr에 두고 비소유 참조를 필요한 범위에서만 사용하면 수명 관계를 더 명확하게 만들 수 있다.

auto widget =
    std::make_unique<Widget>();

draw(*widget);

반복자 무효화

std::vector<int> values {
    10,
    20,
    30
};

auto iterator = values.begin();

values.push_back(40);

// 재할당이 발생하면 iterator는 무효가 된다.

컨테이너별 무효화 규칙을 고려하고, 컨테이너 수정 뒤에는 필요한 반복자를 다시 얻을 수 있다.

데이터 경쟁

int counter = 0;

void increment()
{
    ++counter;
}

여러 스레드가 동기화 없이 동시에 호출하면 데이터 경쟁이 발생할 수 있다.

std::atomic<int> counter = 0;

void increment()
{
    counter.fetch_add(
        1,
        std::memory_order_relaxed
    );
}

여러 값이 하나의 불변 조건을 이룬다면 뮤텍스로 전체 상태를 보호할 수 있다.

class Account {
    mutable std::mutex mutex_;
    Money balance_;

public:
    void deposit(Money amount)
    {
        std::lock_guard lock(mutex_);

        balance_ += amount;
    }

    Money balance() const
    {
        std::lock_guard lock(mutex_);

        return balance_;
    }
};

소유권 중심 인터페이스

함수 인터페이스에서 소유권과 수명을 타입으로 표현하면 호출자가 수행해야 하는 작업을 명확하게 만들 수 있다. C++ Core Guidelines도 자원을 명시적인 소유 객체가 관리하고, 원시 포인터와 참조는 일반적으로 비소유 접근에 사용하도록 권장한다.[69]

새 객체 반환

std::unique_ptr<Texture>
load_texture(
    const std::filesystem::path& path
);

호출자가 단독 소유권을 받는다.

소유권 이전

void add_child(
    std::unique_ptr<Node> child
);

호출자는 std::move를 사용해 소유권을 넘긴다.

auto child =
    std::make_unique<Node>();

parent.add_child(
    std::move(child)
);

비소유 필수 접근

void render(
    const Scene& scene,
    RenderDevice& device
);

호출 동안 두 객체가 반드시 존재해야 한다.

비소유 선택적 접근

void set_parent(
    Node* parent
);

nullptr가 부모 없음이라는 의미를 가질 수 있다.

연속 데이터 접근

void upload(
    std::span<const std::byte> data
);

함수는 저장 공간을 소유하지 않으며 호출 동안만 범위를 관찰한다.

공유 소유권

void schedule(
    std::shared_ptr<TaskState> state
);

함수가 비동기 실행이 끝날 때까지 상태의 공동 소유자가 된다는 의미를 나타낸다.

단순히 함수 호출 동안만 사용할 객체라면 shared_ptr 대신 참조를 받을 수 있다.

void inspect(
    const TaskState& state
);

객체 저장소와 핸들

게임 엔진, ECS, 그래픽스 시스템과 대규모 런타임에서는 객체를 개별 shared_ptr로 연결하기보다 중앙 저장소가 객체를 소유하고 외부에는 안정적인 핸들을 제공하는 구조를 사용할 수 있다.

struct EntityHandle {
    std::uint32_t index;
    std::uint32_t generation;
};

class EntityStorage {
    struct Slot {
        Entity entity;
        std::uint32_t generation;
        bool occupied;
    };

    std::vector<Slot> slots_;

public:
    Entity* get(EntityHandle handle)
    {
        if (handle.index >= slots_.size()) {
            return nullptr;
        }

        Slot& slot = slots_[handle.index];

        if (!slot.occupied ||
            slot.generation != handle.generation) {
            return nullptr;
        }

        return &slot.entity;
    }
};

슬롯이 재사용될 때 generation 값을 증가시키면 오래된 핸들이 우연히 새 객체를 가리키는 문제를 줄일 수 있다.

if (Entity* entity =
        storage.get(handle)) {
    entity->update();
}

이 구조는 다음 특성을 제공할 수 있다.

  • 중앙화된 객체 수명
  • 연속 메모리 배치
  • 일괄 생성과 파괴
  • 오래된 참조 검증
  • 직렬화 가능한 식별자
  • 동적 할당 횟수 감소

포인터 대신 핸들을 사용하는 경우에도 핸들 유효성 검사와 저장소 수명 규칙은 필요하다.

메모리 풀과 아레나

빈번한 같은 크기 객체 할당은 전용 풀로 관리할 수 있다.

template<class T, std::size_t Capacity>
class ObjectPool {
    struct Slot {
        alignas(T)
        std::byte storage[sizeof(T)];

        bool occupied = false;
    };

    std::array<Slot, Capacity> slots_;

public:
    template<class... Arguments>
    T* create(Arguments&&... arguments)
    {
        for (Slot& slot : slots_) {
            if (slot.occupied) {
                continue;
            }

            T* object =
                std::construct_at(
                    reinterpret_cast<T*>(
                        slot.storage
                    ),
                    std::forward<Arguments>(
                        arguments
                    )...
                );

            slot.occupied = true;

            return object;
        }

        throw std::bad_alloc();
    }

    void destroy(T* object)
    {
        std::destroy_at(object);

        Slot* slot =
            slot_from_object(object);

        slot->occupied = false;
    }
};

실제 풀 구현은 다음 요소도 처리해야 한다.

  • 포인터가 실제 풀에 속하는지 검증
  • 중복 반환 방지
  • 타입 정렬
  • 생성자 예외
  • 스레드 동기화
  • 파괴되지 않은 객체 정리
  • 슬롯 검색 성능
  • 객체 주소 안정성
  • 디버그 추적

객체 수명이 모두 같은 단계에서 끝나는 경우에는 개별 반환을 지원하는 풀보다 아레나가 단순할 수 있다.

class Compilation {
    std::pmr::monotonic_buffer_resource arena_;
    std::pmr::vector<AstNode> nodes_;

public:
    Compilation()
        : nodes_(&arena_)
    {
    }
};

Compilation이 파괴되면 모든 중간 데이터에 사용한 저장 공간을 한 번에 반환한다.

예외 안전성

자원 관리 코드는 작업 도중 예외가 발생해도 자원 누수와 잘못된 상태가 발생하지 않도록 구성할 수 있다. 일반적으로 다음 수준의 보장을 구분한다.

보장의미
실패 없음 보장연산이 실패를 외부에 전달하지 않는다
강한 보장실패하면 연산 전 상태가 유지된다
기본 보장실패해도 객체는 유효하고 자원이 누수되지 않는다
보장 없음실패 후 상태와 자원 정리가 보장되지 않는다

RAII는 최소한 자원 누수를 방지하는 기본 보장을 구성하는 핵심 도구다.[70]

복사 후 교환

class Buffer {
public:
    Buffer& operator=(
        const Buffer& other
    )
    {
        Buffer temporary(other);

        swap(temporary);

        return *this;
    }
};

복사 생성 중 예외가 발생하면 원래 객체는 변경되지 않는다. 임시 객체 생성이 성공한 뒤 swap이 예외를 던지지 않으면 강한 예외 보장을 제공할 수 있다.

먼저 준비한 뒤 적용

void Document::replace_lines(
    std::vector<std::string> lines
)
{
    validate(lines);

    lines_ = std::move(lines);
}

새 상태를 함수 인자나 임시 객체에서 완성한 뒤 기존 상태와 교체한다.

void Registry::replace(
    const Key& key,
    Value value
)
{
    auto iterator = values_.find(key);

    if (iterator == values_.end()) {
        values_.emplace(
            key,
            std::move(value)
        );

        return;
    }

    Value old =
        std::move(iterator->second);

    try {
        iterator->second =
            std::move(value);
    }
    catch (...) {
        iterator->second =
            std::move(old);

        throw;
    }
}

실제 보장 수준은 Value의 이동과 대입이 예외를 던지는지 여부에도 영향을 받는다.

이동과 자원 이전

이동 의미론은 자원의 내용을 복제하지 않고 소유권을 새 객체로 이전할 수 있게 한다.

class Socket {
    NativeSocket handle_ =
        invalid_socket();

public:
    explicit Socket(Address address)
        : handle_(
              connect_socket(address)
          )
    {
    }

    ~Socket()
    {
        close_socket(handle_);
    }

    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;

    Socket(Socket&& other) noexcept
        : handle_(
              std::exchange(
                  other.handle_,
                  invalid_socket()
              )
          )
    {
    }

    Socket& operator=(Socket&& other) noexcept
    {
        if (this == &other) {
            return *this;
        }

        close_socket(handle_);

        handle_ = std::exchange(
            other.handle_,
            invalid_socket()
        );

        return *this;
    }
};

이동된 원본 객체는 여전히 유효한 객체지만 더 이상 원래 자원을 소유하지 않는다.

Socket first(address);
Socket second(std::move(first));

// second가 연결을 소유한다.
// first는 파괴 가능하고 다시 대입할 수 있는 상태다.

이동 생성자에 noexcept를 지정하면 표준 컨테이너가 재할당 과정에서 복사 대신 이동을 선택할 수 있다.

std::vector<Socket> sockets;

sockets.emplace_back(first_address);
sockets.emplace_back(second_address);

컨테이너는 기존 원소를 새 저장 공간으로 옮기는 동안 실패 시 보장 수준을 유지해야 하므로, 이동이 예외를 던질 수 있고 복사가 가능하다면 복사를 선택할 수 있다.

메모리 추적과 진단

직접적인 자원 관리 오류는 실행 중에만 드러날 수 있다. 컴파일러와 도구를 사용해 접근 범위, 수명과 누수를 검사할 수 있다.

일반적인 진단 도구는 다음과 같다.

  • AddressSanitizer
  • LeakSanitizer
  • UndefinedBehaviorSanitizer
  • MemorySanitizer
  • ThreadSanitizer
  • 정적 분석기
  • 운영체제별 힙 검증 도구
  • 사용자 정의 할당 추적기
  • 메모리 예산과 태그 시스템

GCC 또는 Clang에서는 다음과 같이 일부 검사기를 활성화할 수 있다.

c++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=address,undefined \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

스레드 경쟁 검사기는 별도 빌드로 실행할 수 있다.

c++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=thread \
    main.cpp \
    -o application

검사기 지원 범위와 함께 사용할 수 있는 옵션은 컴파일러와 플랫폼에 따라 달라진다.

사용자 정의 메모리 태그를 추가할 수도 있다.

enum class MemoryTag {
    Renderer,
    Physics,
    Audio,
    Network,
    Editor
};

void* allocate(
    std::size_t size,
    std::size_t alignment,
    MemoryTag tag
);
struct MemoryStatistics {
    std::size_t current_bytes;
    std::size_t peak_bytes;
    std::size_t allocation_count;
};

MemoryStatistics statistics(
    MemoryTag tag
);

모듈별 현재 사용량과 최고 사용량, 할당 횟수를 추적해 예산을 관리할 수 있다.

자원 관리 구조의 선택

C++에서는 자원의 종류와 소유 관계에 따라 여러 관리 구조를 선택할 수 있다.

상황일반적인 구조
클래스의 고정 멤버값 멤버
가변 길이 연속 데이터std::vector
문자열 소유std::string
문자열 비소유 관찰std::string_view
연속 데이터 비소유 관찰std::span
단독 동적 소유std::unique_ptr
실제 공동 소유std::shared_ptr
공동 객체의 비소유 관찰std::weak_ptr
범위 동안 잠금std::lock_guard, std::unique_lock
범위 종료 정리전용 RAII 타입, std::scope_exit
같은 단계에서 일괄 폐기아레나, std::pmr::monotonic_buffer_resource
작은 반복 할당풀 자원
실행 시간 할당 전략 교체std::pmr::memory_resource
외부 C 핸들사용자 정의 삭제자를 가진 unique_ptr 또는 전용 클래스
안정적인 런타임 식별중앙 저장소와 세대형 핸들

일반적인 자원 관리 흐름

C++ 프로그램은 최상위 객체가 하위 시스템을 값 또는 단독 소유 포인터로 소유하도록 구성할 수 있다.

class Application {
    Window window_;
    RenderDevice renderer_;
    AudioDevice audio_;
    PhysicsWorld physics_;
    GameWorld world_;

public:
    explicit Application(
        const Configuration& configuration
    )
        : window_(configuration.window),
          renderer_(
              window_,
              configuration.renderer
          ),
          audio_(configuration.audio),
          physics_(configuration.physics),
          world_(
              renderer_,
              audio_,
              physics_
          )
    {
    }

    void run()
    {
        while (!window_.should_close()) {
            update();
            render();
        }
    }
};

멤버는 선언 순서대로 생성되고 역순으로 파괴된다.

생성
Window
→ RenderDevice
→ AudioDevice
→ PhysicsWorld
→ GameWorld

파괴
GameWorld
→ PhysicsWorld
→ AudioDevice
→ RenderDevice
→ Window

RenderDeviceWindow보다 먼저 파괴되므로 렌더링 백엔드가 네이티브 창을 사용하는 동안 창이 살아 있다.

동적 선택이 필요한 백엔드는 단독 소유 포인터로 둘 수 있다.

class Application {
    Window window_;

    std::unique_ptr<RenderDevice>
        renderer_;

public:
    explicit Application(
        const Configuration& configuration
    )
        : window_(configuration.window),
          renderer_(
              create_render_device(
                  window_,
                  configuration.renderer
              )
          )
    {
    }
};

팩토리는 구체적 구현의 소유권을 반환한다.

std::unique_ptr<RenderDevice>
create_render_device(
    Window& window,
    RendererBackend backend
)
{
    switch (backend) {
        case RendererBackend::Vulkan:
            return std::make_unique<
                VulkanRenderDevice
            >(window);

        case RendererBackend::D3D12:
            return std::make_unique<
                D3D12RenderDevice
            >(window);
    }

    throw RendererError(
        "지원하지 않는 백엔드다."
    );
}

외부에서 렌더러를 사용할 때는 소유 포인터가 아니라 참조를 전달한다.

void render_world(
    RenderDevice& renderer,
    const World& world
)
{
}

자원 관리의 핵심 원칙

C++의 자원 관리 구조는 다음과 같이 정리할 수 있다.

  1. 자원은 가능한 한 생성 직후 소유 객체에 넣는다.
  2. 자원을 획득한 객체가 소멸자에서 반환한다.
  3. 직접 소유할 수 있는 하위 객체는 값 멤버로 둔다.
  4. 단독 동적 소유는 std::unique_ptr로 표현한다.
  5. 실제로 여러 소유자가 필요한 경우에만 std::shared_ptr를 사용한다.
  6. 비소유 접근은 참조, 포인터, 반복자와 뷰로 표현한다.
  7. 원시 포인터만으로 소유권을 전달하는 인터페이스는 별도 계약을 명시한다.
  8. 동적 배열과 일반 자료구조는 표준 컨테이너에 맡긴다.
  9. 직접 newdelete를 작성하는 코드는 저수준 소유 타입 내부로 제한할 수 있다.
  10. 메모리 풀과 아레나는 객체 수명 패턴이 명확할 때 적용한다.
  11. 객체 수명과 저장 공간의 수명을 구분한다.
  12. 예외와 조기 반환을 포함한 모든 실행 경로에서 자원이 반환되도록 한다.
  13. 검사기와 할당 추적을 사용해 수명 오류를 조기에 발견한다.

C++는 모든 메모리를 자동으로 추적하는 단일 런타임 관리자를 요구하지 않는다. 대신 객체의 결정적인 파괴 시점, 값 타입, 이동 의미론, 스마트 포인터, 컨테이너와 사용자 정의 할당 구조를 조합해 자원의 소유권과 수명을 프로그램 구조에 직접 표현한다.

이 모델에서는 메모리를 직접 할당하고 해제할 수 있지만, 일반적인 상위 코드가 모든 할당을 수동으로 추적할 필요는 없다. 직접 자원을 다루는 코드를 작은 RAII 타입과 저장소 내부에 캡슐화하고, 나머지 프로그램은 그 타입을 값처럼 조합하는 방식으로 자원 관리 경계를 구성할 수 있다.

템플릿과 제네릭 프로그래밍

C++의 템플릿은 타입, 상수 값 또는 다른 템플릿을 매개변수로 받아 클래스, 함수, 변수, 타입 별칭과 개념의 계열을 정의하는 언어 기능이다. 템플릿을 사용하면 특정한 하나의 타입에 종속되지 않는 자료구조와 알고리즘을 작성하고, 실제 사용 지점에서 전달된 템플릿 인자에 맞는 구체적인 선언을 생성할 수 있다.

제네릭 프로그래밍은 구현할 알고리즘을 특정 클래스 계층이나 하나의 자료형에 결합하기보다, 필요한 연산과 의미적 요구 사항을 만족하는 여러 타입에 적용할 수 있도록 작성하는 프로그래밍 방식이다. C++에서는 템플릿이 이러한 매개변수화를 제공하고, 개념과 제약 조건이 템플릿 인자에 요구되는 인터페이스를 표현한다.

C++ 표준은 템플릿을 클래스, 함수 또는 변수의 계열, 타입 별칭의 계열, 또는 개념을 정의하는 구조로 규정한다.[71]

template<class T>
T maximum(T left, T right)
{
    return left < right
        ? right
        : left;
}

이 함수는 하나의 구체적인 함수가 아니라, T에 전달되는 타입에 따라 여러 함수 특수화를 만들 수 있는 함수 템플릿이다.

int integer =
    maximum(10, 20);

double real =
    maximum(1.5, 3.5);

std::string text =
    maximum(
        std::string("C"),
        std::string("C++")
    );

각 호출에서 T는 다음과 같이 추론된다.

maximum(10, 20)
→ T = int
→ maximum<int>(int, int)

maximum(1.5, 3.5)
→ T = double
→ maximum<double>(double, double)

maximum(std::string(...), std::string(...))
→ T = std::string
→ maximum<std::string>(std::string, std::string)

템플릿은 소스 코드를 단순히 문자 단위로 복제하는 매크로가 아니다. 컴파일러는 템플릿 선언의 문법을 분석하고, 인스턴스화된 각 타입에 대해 이름 탐색, 타입 검사, 오버로드 결정과 제약 조건 검사를 수행한다.

C++ 템플릿의 주요 구성
  • 템플릿
    • 템플릿 매개변수
      • 타입 매개변수
      • 상수 템플릿 매개변수
      • 템플릿 템플릿 매개변수
    • 템플릿 선언
      • 함수 템플릿
      • 클래스 템플릿
      • 변수 템플릿
      • 별칭 템플릿
      • 개념
    • 구체화
      • 암시적 인스턴스화
      • 명시적 인스턴스화
      • 명시적 특수화
    • 추론
      • 함수 템플릿 인자 추론
      • 클래스 템플릿 인자 추론
      • 추론 가이드
    • 제약
      • 개념
      • requires 표현식
      • requires
      • 제약된 오버로드

템플릿 매개변수

템플릿 매개변수는 템플릿이 구체화될 때 외부에서 전달받는 값을 나타낸다. C++ 템플릿은 타입, 컴파일 시간 상수와 다른 템플릿을 매개변수로 받을 수 있다.

타입 템플릿 매개변수

타입 템플릿 매개변수는 구체화 시 하나의 타입으로 대체된다.

template<class T>
class Box {
    T value_;

public:
    explicit Box(T value)
        : value_(std::move(value))
    {
    }

    const T& value() const noexcept
    {
        return value_;
    }
};
Box<int> integer_box(42);

Box<std::string> string_box(
    "C++"
);

Box<int>Box<std::string>은 서로 다른 클래스 타입이다.

static_assert(
    !std::is_same_v<
        Box<int>,
        Box<std::string>
    >
);

타입 매개변수 선언에는 classtypename을 모두 사용할 수 있다.

template<class T>
class First {
};

template<typename T>
class Second {
};

이 문맥에서 classtypename은 같은 의미를 가진다. 매개변수가 실제 클래스 타입으로만 제한된다는 뜻은 아니다.

First<int> integer;
First<double*> pointer;
First<void(*)()> function_pointer;

기본 템플릿 인자

템플릿 매개변수에 기본 인자를 지정할 수 있다.

template<
    class T,
    class Allocator = std::allocator<T>
>
class DynamicArray {
};
DynamicArray<int> first;

DynamicArray<
    int,
    CustomAllocator<int>
> second;

첫 번째 선언은 기본 할당자인 std::allocator<int>를 사용한다.

표준 컨테이너도 원소 타입 외에 할당자, 비교 함수와 해시 함수 등의 템플릿 인자를 가진다.

std::vector<int> values;

std::map<
    std::string,
    int,
    std::less<>
> versions;

상수 템플릿 매개변수

상수 템플릿 매개변수는 컴파일 시간 값을 템플릿의 일부로 받는다.

template<
    class T,
    std::size_t Size
>
class FixedArray {
    T data_[Size] {};

public:
    constexpr std::size_t size() const noexcept
    {
        return Size;
    }

    constexpr T& operator[](
        std::size_t index
    ) noexcept
    {
        return data_[index];
    }

    constexpr const T& operator[](
        std::size_t index
    ) const noexcept
    {
        return data_[index];
    }
};
FixedArray<int, 16> small;
FixedArray<int, 1024> large;

크기도 타입의 일부이므로 두 객체는 서로 다른 타입이다.

static_assert(
    !std::is_same_v<
        FixedArray<int, 16>,
        FixedArray<int, 1024>
    >
);

상수 템플릿 매개변수는 배열 크기 외에도 정책 플래그, 정렬, 식별자와 컴파일 시간 문자열 등에 사용할 수 있다.

template<
    class T,
    bool ThreadSafe
>
class ObjectPool {
};
template<
    class T,
    std::size_t Alignment
>
struct AlignedValue {
    alignas(Alignment)
    T value;
};

AlignedValue<float, 64> cache_value;

C++20부터 특정 구조적 클래스 타입도 상수 템플릿 인자로 사용할 수 있다.

struct Size {
    int width;
    int height;

    constexpr bool operator==(
        const Size&
    ) const = default;
};

template<Size Value>
class Surface {
public:
    static constexpr int width =
        Value.width;

    static constexpr int height =
        Value.height;
};

using MainSurface =
    Surface<Size {1920, 1080}>;

구조적 타입이 상수 템플릿 인자로 사용될 수 있는 범위는 해당 타입의 구성과 표준 규칙에 의해 제한된다.

auto 상수 템플릿 매개변수

상수 템플릿 매개변수의 타입을 auto로 추론할 수 있다.

template<auto Value>
struct Constant {
    static constexpr auto value = Value;
};
using Integer =
    Constant<42>;

using Character =
    Constant<'A'>;

using Pointer =
    Constant<&global_object>;

템플릿 인자의 값에 따라 매개변수 타입도 함께 결정된다.

템플릿 템플릿 매개변수

템플릿 템플릿 매개변수는 하나의 타입이 아니라 다른 템플릿을 인자로 받는다.

template<
    class T,
    template<class, class> class Container
>
class Collection {
    Container<
        T,
        std::allocator<T>
    > values_;

public:
    void add(T value)
    {
        values_.push_back(
            std::move(value)
        );
    }
};
Collection<int, std::vector>
    integers;

Collection<std::string, std::deque>
    strings;

컨테이너 템플릿 자체를 정책처럼 교체할 수 있다.

현대 C++에서는 구체적인 템플릿 형태에 강하게 의존하기보다 범위 개념이나 필요한 연산을 기준으로 제약하는 방식도 사용할 수 있다.

template<std::ranges::range Range>
void process_range(
    Range&& range
)
{
    for (auto&& value : range) {
        process(value);
    }
}

함수 템플릿

함수 템플릿은 서로 다른 타입에 적용되는 함수 계열을 정의한다.

template<class T>
T square(T value)
{
    return value * value;
}
int integer = square(12);
double real = square(1.5);

함수 템플릿의 템플릿 인자는 대부분 호출 인자에서 추론되므로 명시적으로 작성하지 않아도 된다.

double value =
    square<double>(10);

명시적 인자를 작성하면 함수 인자의 변환이 허용될 수 있다.

double result =
    maximum<double>(
        10,
        20.5
    );

T가 명시적으로 double로 정해졌으므로 첫 번째 인자 10double로 변환된다.

여러 타입 매개변수

template<
    class Left,
    class Right
>
auto add(
    const Left& left,
    const Right& right
)
{
    return left + right;
}
auto first = add(10, 20.5);
auto second = add(
    std::string("C"),
    "++"
);

반환형은 left + right 표현식에서 추론된다.

후행 반환형으로 결과 타입을 명시할 수도 있다.

template<
    class Left,
    class Right
>
auto multiply(
    const Left& left,
    const Right& right
)
    -> decltype(left * right)
{
    return left * right;
}

공통 타입

서로 다른 산술 타입을 하나의 결과 타입으로 변환하려면 std::common_type을 사용할 수 있다.

#include <type_traits>

template<
    class Left,
    class Right
>
auto maximum(
    Left left,
    Right right
)
    -> std::common_type_t<
        Left,
        Right
    >
{
    using Result =
        std::common_type_t<
            Left,
            Right
        >;

    if (left < right) {
        return static_cast<Result>(right);
    }

    return static_cast<Result>(left);
}
auto value =
    maximum(10, 20.5);

static_assert(
    std::is_same_v<
        decltype(value),
        double
    >
);

함수 템플릿 오버로딩

함수 템플릿도 일반 함수처럼 오버로드할 수 있다.

template<class T>
void print(const T& value)
{
    print_generic(value);
}

template<class T>
void print(T* pointer)
{
    if (pointer) {
        print_generic(*pointer);
    }
}
int value = 42;

print(value);
print(&value);

포인터 인자에는 T* 형태의 오버로드가 더 구체적으로 일치한다.

일반 함수와 함수 템플릿을 함께 오버로드할 수도 있다.

void print(int value)
{
    print_integer(value);
}

template<class T>
void print(const T& value)
{
    print_generic(value);
}

정확히 일치하는 비템플릿 함수가 있으면 일반적으로 해당 함수가 선택될 수 있다.

print(42);
print(3.14);

첫 번째 호출은 print(int), 두 번째 호출은 함수 템플릿을 사용한다.

클래스 템플릿

클래스 템플릿은 타입이나 상수 값에 따라 서로 다른 클래스 타입의 계열을 정의한다.

#include <stdexcept>
#include <utility>
#include <vector>

template<class T>
class Stack {
    std::vector<T> values_;

public:
    bool empty() const noexcept
    {
        return values_.empty();
    }

    std::size_t size() const noexcept
    {
        return values_.size();
    }

    void push(T value)
    {
        values_.push_back(
            std::move(value)
        );
    }

    T pop()
    {
        if (values_.empty()) {
            throw std::out_of_range(
                "스택이 비어 있다."
            );
        }

        T value =
            std::move(
                values_.back()
            );

        values_.pop_back();

        return value;
    }
};
Stack<int> integers;
integers.push(10);
integers.push(20);

Stack<std::string> strings;
strings.push("C++");

각 클래스 특수화는 해당 원소 타입에 맞는 멤버를 가진다.

클래스 외부 멤버 정의

클래스 템플릿의 멤버를 클래스 바깥에서 정의할 때도 템플릿 매개변수를 다시 작성한다.

template<class T>
class Counter {
    T value_ {};

public:
    void add(const T& value);
    const T& value() const noexcept;
};

template<class T>
void Counter<T>::add(
    const T& value
)
{
    value_ += value;
}

template<class T>
const T& Counter<T>::value() const noexcept
{
    return value_;
}

Counter<T>::는 아직 구체화되지 않은 클래스 템플릿 특수화의 범위를 나타낸다.

템플릿 내부의 타입 별칭

template<class T>
class Container {
public:
    using value_type = T;
    using reference = T&;
    using const_reference = const T&;
    using size_type = std::size_t;
};

표준 컨테이너도 이와 같은 멤버 타입을 제공한다.

using Value =
    std::vector<int>::value_type;

using Iterator =
    std::vector<int>::iterator;

정적 데이터 멤버

클래스 템플릿의 각 특수화는 별도의 정적 데이터 멤버를 가질 수 있다.

template<class T>
class ObjectCounter {
    inline static std::size_t count_ = 0;

public:
    ObjectCounter()
    {
        ++count_;
    }

    ~ObjectCounter()
    {
        --count_;
    }

    static std::size_t count() noexcept
    {
        return count_;
    }
};
ObjectCounter<int> first;
ObjectCounter<int> second;
ObjectCounter<double> third;

ObjectCounter<int>::count_ObjectCounter<double>::count_는 서로 다른 정적 객체다.

변수 템플릿

변수 템플릿은 타입이나 값에 따라 변수 또는 상수의 계열을 정의한다.

template<class T>
inline constexpr T pi =
    T(3.1415926535897932384626433832795L);
constexpr float float_pi =
    pi<float>;

constexpr double double_pi =
    pi<double>;

constexpr long double long_pi =
    pi<long double>;

타입 특성도 변수 템플릿 형태로 제공할 수 있다.

template<class T>
inline constexpr bool is_pointer =
    false;

template<class T>
inline constexpr bool is_pointer<T*> =
    true;
static_assert(
    is_pointer<int*>
);

static_assert(
    !is_pointer<int>
);

표준 라이브러리의 _v 접미사 타입 특성도 이러한 변수 템플릿 형태를 사용한다.

static_assert(
    std::is_integral_v<int>
);

static_assert(
    std::is_pointer_v<void*>
);

별칭 템플릿

별칭 템플릿은 템플릿 인자에 따라 타입 별칭의 계열을 만든다.

template<class T>
using Owner =
    std::unique_ptr<T>;
Owner<Texture> texture;
Owner<RenderDevice> renderer;

중첩된 복잡한 타입을 단순화할 수 있다.

template<
    class Key,
    class Value
>
using StringMap =
    std::unordered_map<
        std::string,
        Value
    >;

이 예제에서는 사용되지 않는 Key 매개변수를 둘 필요가 없으므로 다음과 같이 단순화할 수 있다.

template<class Value>
using StringMap =
    std::unordered_map<
        std::string,
        Value
    >;

StringMap<int> versions;
StringMap<Texture> textures;

타입 변환 특성도 별칭 템플릿으로 표현할 수 있다.

template<class T>
using RemoveCVReference =
    std::remove_cv_t<
        std::remove_reference_t<T>
    >;

현대 표준 라이브러리는 같은 기능을 std::remove_cvref_t로 제공한다.

using Type =
    std::remove_cvref_t<
        const int&
    >;

static_assert(
    std::is_same_v<
        Type,
        int
    >
);

템플릿 인스턴스화

템플릿 선언만으로 모든 가능한 타입의 코드가 즉시 생성되는 것은 아니다. 프로그램에서 특정 템플릿 특수화가 필요해질 때 구체적인 선언 또는 정의가 인스턴스화된다.

template<class T>
T double_value(T value)
{
    return value * 2;
}

int integer =
    double_value(10);

double real =
    double_value(1.5);

이 프로그램에서는 개념적으로 다음 함수 특수화가 필요하다.

int double_value<int>(
    int value
)
{
    return value * 2;
}

double double_value<double>(
    double value
)
{
    return value * 2;
}

실제 컴파일러가 반드시 이 소스 형태를 중간 결과로 생성하는 것은 아니다. 표준은 프로그램의 의미를 규정하며, 구현체는 동일한 결과를 만드는 다른 번역 방식을 사용할 수 있다.

암시적 인스턴스화

사용 지점에서 완전한 정의가 필요하면 컴파일러가 템플릿을 암시적으로 인스턴스화한다.

template<class T>
class Box {
    T value_;
};

Box<int> box;

Box<int> 객체를 만들려면 클래스의 완전한 정의가 필요하다.

포인터만 선언하는 경우에는 즉시 완전한 클래스 특수화가 필요하지 않을 수 있다.

Box<double>* pointer = nullptr;

명시적 인스턴스화 정의

특정 템플릿 특수화의 코드를 한 번역 단위에서 명시적으로 생성할 수 있다.

// vector_math.cpp

template<class T>
class VectorMath {
public:
    T length() const;
};

template class VectorMath<float>;
template class VectorMath<double>;

명시적 인스턴스화 선언

다른 번역 단위에서 중복 인스턴스화를 억제하기 위해 extern template을 사용할 수 있다.

// vector_math.hpp

extern template
class VectorMath<float>;

extern template
class VectorMath<double>;

하나의 구현 파일에서 명시적 인스턴스화 정의를 제공한다.

// vector_math.cpp

#include "vector_math.hpp"

template class VectorMath<float>;
template class VectorMath<double>;

이 방식은 자주 사용되는 대형 템플릿 특수화의 빌드 시간과 목적 파일 중복을 줄이는 데 사용할 수 있다.

템플릿 정의의 배치

템플릿은 인스턴스화되는 지점에서 필요한 정의를 볼 수 있어야 하는 경우가 많다. 따라서 템플릿 구현은 일반적으로 헤더 파일이나 모듈 인터페이스 안에 배치된다.

// maximum.hpp

#pragma once

template<class T>
const T& maximum(
    const T& left,
    const T& right
)
{
    return left < right
        ? right
        : left;
}

템플릿 선언만 헤더에 두고 정의를 일반 소스 파일에 숨기면, 다른 번역 단위에서 임의 타입으로 인스턴스화할 수 없다.

// maximum.hpp

template<class T>
const T& maximum(
    const T& left,
    const T& right
);
// maximum.cpp

template<class T>
const T& maximum(
    const T& left,
    const T& right
)
{
    return left < right
        ? right
        : left;
}

이 구조에서는 maximum<CustomType>의 정의를 사용하는 번역 단위가 템플릿 본문을 볼 수 없다.

지원할 타입 집합이 고정돼 있다면 소스 파일에서 명시적으로 인스턴스화할 수 있다.

template const int& maximum<int>(
    const int&,
    const int&
);

template const double& maximum<double>(
    const double&,
    const double&
);

명시적 특수화

명시적 특수화는 특정 템플릿 인자 조합에 대해 기본 템플릿과 다른 구현을 제공한다.

template<class T>
struct TypeName {
    static constexpr
        std::string_view value =
            "unknown";
};
template<>
struct TypeName<int> {
    static constexpr
        std::string_view value =
            "int";
};

template<>
struct TypeName<double> {
    static constexpr
        std::string_view value =
            "double";
};
static_assert(
    TypeName<int>::value == "int"
);

static_assert(
    TypeName<float>::value ==
        "unknown"
);

함수 템플릿도 명시적으로 특수화할 수 있다.

template<class T>
void serialize(
    Writer& writer,
    const T& value
)
{
    value.serialize(writer);
}

template<>
void serialize<std::string>(
    Writer& writer,
    const std::string& value
)
{
    writer.write_string(value);
}

함수 템플릿에서는 특수화보다 함수 오버로딩이 더 자연스러운 경우가 많다.

template<class T>
void serialize(
    Writer& writer,
    const T& value
)
{
    value.serialize(writer);
}

void serialize(
    Writer& writer,
    const std::string& value
)
{
    writer.write_string(value);
}

오버로드는 일반적인 함수 선택 규칙과 함께 작동하며 템플릿 특수화보다 인터페이스를 확장하기 쉬울 수 있다.

부분 특수화

부분 특수화는 템플릿 인자의 일부 형태에 대해 기본 템플릿보다 더 구체적인 구현을 제공한다.

클래스 템플릿과 변수 템플릿은 부분 특수화할 수 있다.

template<class T>
struct TypeCategory {
    static constexpr
        std::string_view value =
            "value";
};

포인터 타입 전체에 대한 부분 특수화를 만들 수 있다.

template<class T>
struct TypeCategory<T*> {
    static constexpr
        std::string_view value =
            "pointer";
};
static_assert(
    TypeCategory<int>::value ==
        "value"
);

static_assert(
    TypeCategory<int*>::value ==
        "pointer"
);

static_assert(
    TypeCategory<double*>::value ==
        "pointer"
);

배열 타입에 대한 부분 특수화도 가능하다.

template<
    class T,
    std::size_t Size
>
struct TypeCategory<T[Size]> {
    static constexpr
        std::string_view value =
            "fixed array";
};

여러 부분 특수화가 일치하면 가장 구체적인 특수화가 선택된다.

template<class T>
struct Traits;

template<class T>
struct Traits<T*> {
    static constexpr int rank = 1;
};

template<class T>
struct Traits<const T*> {
    static constexpr int rank = 2;
};
static_assert(
    Traits<int*>::rank == 1
);

static_assert(
    Traits<const int*>::rank == 2
);

함수 템플릿에는 부분 특수화 문법이 없다. 비슷한 효과는 오버로딩과 제약 조건으로 구성한다.

template<class T>
void process(const T& value)
{
    process_value(value);
}

template<class T>
void process(T* pointer)
{
    if (pointer) {
        process_value(*pointer);
    }
}

가변 인자 템플릿

가변 인자 템플릿은 개수가 정해지지 않은 템플릿 인자나 함수 인자를 받을 수 있다.

template<class... Types>
class TypeList {
};

Types는 타입 매개변수 팩이다.

using Empty =
    TypeList<>;

using Values =
    TypeList<
        int,
        double,
        std::string
    >;

함수 매개변수 팩도 사용할 수 있다.

template<class... Arguments>
void log(
    Arguments&&... arguments
)
{
    print_all(
        std::forward<Arguments>(
            arguments
        )...
    );
}

Arguments...는 타입 팩이고 arguments...는 함수 인자 팩이다.

팩 확장

말줄임표 ...는 패턴을 매개변수 팩의 각 요소에 대해 확장한다.

template<class... Types>
void create_all()
{
    (
        create<Types>(),
        ...
    );
}
create_all<
    Renderer,
    AudioDevice,
    PhysicsWorld
>();

개념적으로 각 타입에 대해 호출이 확장된다.

create<Renderer>();
create<AudioDevice>();
create<PhysicsWorld>();

sizeof...

매개변수 팩의 원소 개수는 sizeof...로 확인한다.

template<class... Types>
inline constexpr std::size_t
    type_count =
        sizeof...(Types);

static_assert(
    type_count<> == 0
);

static_assert(
    type_count<int, double> == 2
);

함수 인자 개수도 확인할 수 있다.

template<class... Arguments>
void call(
    Arguments&&... arguments
)
{
    constexpr std::size_t count =
        sizeof...(Arguments);

    report_argument_count(count);
}

재귀적 가변 템플릿

C++17 이전에는 가변 인자를 재귀적으로 처리하는 패턴이 널리 사용됐다.

void print_all()
{
}
template<
    class First,
    class... Rest
>
void print_all(
    const First& first,
    const Rest&... rest
)
{
    print(first);

    print_all(rest...);
}

각 호출에서 첫 번째 인자를 처리하고 나머지 팩으로 다시 호출한다.

폴드 표현식

C++17의 폴드 표현식은 매개변수 팩에 이항 연산자를 반복 적용한다. 폴드 표현식은 팩을 연산자 시퀀스로 확장하기 위해 도입됐다.[72]

template<class... Values>
auto sum(Values... values)
{
    return (
        values + ...
    );
}
int result =
    sum(1, 2, 3, 4);

개념적으로 다음과 유사한 연산을 만든다.

int result =
    1 + (2 + (3 + 4));

초깃값을 제공할 수 있다.

template<class... Values>
auto sum(Values... values)
{
    return (
        Values {} + ... + values
    );
}

모든 인자에 함수를 적용할 수도 있다.

template<class... Values>
void print_all(
    const Values&... values
)
{
    (
        print(values),
        ...
    );
}

논리 연산에 사용할 수 있다.

template<class... Types>
inline constexpr bool
    all_integral =
        (
            std::is_integral_v<Types>
            && ...
        );
static_assert(
    all_integral<
        int,
        long,
        unsigned int
    >
);

static_assert(
    !all_integral<
        int,
        double
    >
);

템플릿 인자 추론

함수 템플릿을 호출할 때 컴파일러는 함수 템플릿 매개변수 타입과 실제 호출 인자 타입을 비교해 템플릿 인자를 추론한다.[73]

template<class T>
void process(T value)
{
}

process(42);
process(3.14);

첫 번째 호출에서는 T = int, 두 번째 호출에서는 T = double이 추론된다.

값 매개변수 추론

값으로 받는 매개변수는 일반적으로 최상위 참조와 cv 한정을 제거한 타입을 추론한다.

template<class T>
void inspect(T value)
{
    static_assert(
        std::is_same_v<T, int>
    );
}

const int source = 42;

inspect(source);

sourceconst int지만 값 복사본을 만들기 때문에 Tint로 추론된다.

참조 매개변수 추론

template<class T>
void inspect(T& value)
{
}

const int source = 42;

inspect(source);

이 경우 Tconst int로 추론되어 최종 매개변수 타입은 const int&가 된다.

같은 템플릿 매개변수의 충돌

template<class T>
T maximum(T left, T right)
{
    return left < right
        ? right
        : left;
}

// maximum(10, 20.5);

첫 번째 인자에서는 T = int, 두 번째 인자에서는 T = double이 추론되므로 하나의 T를 결정할 수 없다.

템플릿 매개변수를 분리할 수 있다.

template<
    class Left,
    class Right
>
auto maximum(
    Left left,
    Right right
)
{
    return left < right
        ? right
        : left;
}

조건 연산자의 공통 타입 규칙에 따라 결과 타입이 결정된다.

추론되지 않는 문맥

일부 템플릿 매개변수 사용 위치에서는 호출 인자만으로 타입을 추론하지 않는다.

template<class T>
void assign(
    T& destination,
    std::type_identity_t<T> value
)
{
    destination = value;
}

두 번째 매개변수의 T는 추론되지 않는 문맥에 있으므로 첫 번째 인자에서 정해진 타입을 사용한다.

double value = 0.0;

assign(value, 42);

첫 번째 인자에서 T = double이 정해지고 정수 42double로 변환된다.

전달 참조와 완벽 전달

함수 템플릿에서 추론되는 형태의 T&&는 lvalue와 rvalue를 모두 받을 수 있는 전달 참조가 될 수 있다.

template<class T>
void relay(T&& value)
{
    consume(
        std::forward<T>(value)
    );
}
Widget widget;

relay(widget);
relay(std::move(widget));
relay(Widget {});

lvalue를 전달하면 T가 lvalue 참조 타입으로 추론된다.

relay(widget)

T = Widget&
T&& = Widget& &&

참조 축약 후
Widget&

rvalue를 전달하면 T는 비참조 타입으로 추론된다.

relay(Widget {})

T = Widget
T&& = Widget&&

참조 축약 규칙은 다음과 같이 정리할 수 있다.

T&  &  → T&
T&  && → T&
T&& &  → T&
T&& && → T&&

std::forward<T>는 원래 호출자의 값 범주를 유지해 다음 함수로 전달한다.

template<
    class T,
    class... Arguments
>
std::unique_ptr<T>
make_object(
    Arguments&&... arguments
)
{
    return std::unique_ptr<T>(
        new T(
            std::forward<Arguments>(
                arguments
            )...
        )
    );
}

표준 라이브러리의 std::make_unique가 같은 역할을 제공하므로 일반 코드에서는 이를 사용할 수 있다.

auto widget =
    std::make_unique<Widget>(
        1280,
        720,
        "Main"
    );

전달 생성자의 범위 제한

모든 타입을 받는 전달 생성자는 복사 생성자와 경쟁할 수 있다.

class Wrapper {
public:
    Wrapper(const Wrapper&) = default;

    template<class T>
    Wrapper(T&& value)
    {
        initialize(
            std::forward<T>(value)
        );
    }
};

개념으로 허용할 타입을 제한할 수 있다.

template<class T>
concept WrapperSource =
    !std::same_as<
        std::remove_cvref_t<T>,
        Wrapper
    >;

class Wrapper {
public:
    Wrapper(const Wrapper&) = default;

    template<WrapperSource T>
    explicit Wrapper(T&& value)
    {
        initialize(
            std::forward<T>(value)
        );
    }
};

클래스 템플릿 인자 추론

C++17부터 클래스 템플릿 객체를 생성할 때 생성자 인자로 템플릿 인자를 추론할 수 있다. 이를 클래스 템플릿 인자 추론, 또는 CTAD라고 한다.

std::pair pair(
    10,
    20.5
);

pair의 타입은 std::pair<int, double>로 추론된다.

std::vector values {
    10,
    20,
    30
};

원소를 기준으로 std::vector<int>가 추론된다.

사용자 정의 클래스에서도 생성자를 기준으로 추론할 수 있다.

template<class T>
class Box {
    T value_;

public:
    explicit Box(T value)
        : value_(std::move(value))
    {
    }
};

Box integer(42);
Box real(3.14);

추론 가이드

기본 추론과 다른 타입을 선택하려면 추론 가이드를 작성할 수 있다.

template<class T>
class Box {
    T value_;

public:
    explicit Box(T value)
        : value_(std::move(value))
    {
    }
};

Box(const char*)
    -> Box<std::string>;
Box first(42);
Box second("C++");

firstBox<int>, secondBox<std::string>이 된다.

반복자 쌍에서 원소 타입을 추론하는 가이드를 만들 수도 있다.

template<class T>
class Buffer {
public:
    template<class Iterator>
    Buffer(
        Iterator first,
        Iterator last
    );
};

template<class Iterator>
Buffer(Iterator, Iterator)
    -> Buffer<
        typename std::iterator_traits<
            Iterator
        >::value_type
    >;

의존 이름

템플릿 정의 안에서 의미가 템플릿 인자에 따라 달라지는 이름을 의존 이름이라고 한다.

template<class Container>
void process(
    const Container& container
)
{
    typename Container::value_type value {};
}

Container::value_type이 타입인지 정적 데이터 멤버인지 템플릿 정의 시점에는 알 수 없으므로 typename을 사용해 타입임을 표시한다.

의존 템플릿 이름

template<class Object>
void convert(Object& object)
{
    object.template convert_to<int>();
}

object의 타입이 템플릿 인자에 의존하므로 convert_to가 템플릿이라는 사실을 template 구분자로 표시한다.

현재 인스턴스화

template<class T>
class Tree {
    using Node = Tree<T>;

    Node* parent_ = nullptr;
};

Tree<T>는 현재 인스턴스화를 나타낸다.

두 단계 이름 탐색

템플릿의 일부 이름은 템플릿을 정의할 때 찾고, 템플릿 인자에 의존하는 이름은 인스턴스화 시점의 정보까지 고려한다.

void process(int);

template<class T>
void call(T value)
{
    process(value);
}

process처럼 템플릿 인자에 의존하지 않는 이름은 템플릿 정의 시점에 일반 이름 탐색을 수행한다.

인자 타입과 관련된 함수는 인자 의존 이름 탐색으로 인스턴스화 시점에 발견될 수 있다.

namespace graphics {
    struct Texture {
    };

    void upload(
        const Texture&
    )
    {
    }
}

template<class T>
void send(const T& value)
{
    upload(value);
}

graphics::Texture texture;

send(texture);

Texture와 연관된 graphics 이름공간에서 upload를 찾을 수 있다.

템플릿 코드는 포함 순서에 우연히 의존하지 않도록 필요한 선언과 사용자 정의 지점을 명확하게 배치해야 한다.

SFINAE

SFINAE는 “Substitution Failure Is Not An Error”의 약자로, 함수 템플릿 인자를 치환하는 과정에서 특정 타입이나 표현식이 유효하지 않더라도 전체 프로그램을 즉시 오류로 만들지 않고 해당 함수 특수화를 오버로드 후보에서 제외하는 규칙이다.

template<
    class T,
    std::enable_if_t<
        std::is_integral_v<T>,
        int
    > = 0
>
T double_value(T value)
{
    return value * 2;
}
int integer =
    double_value(10);

// double real =
//     double_value(1.5);

double은 조건을 만족하지 않으므로 해당 특수화가 후보에서 제외된다.

반환형에 적용할 수도 있다.

template<class T>
auto size_of(
    const T& value
)
    -> decltype(value.size())
{
    return value.size();
}

value.size()가 유효한 타입에만 함수가 후보가 된다.

탐지 관용구를 사용할 수도 있다.

template<
    class T,
    class = void
>
struct HasSize :
    std::false_type {
};

template<class T>
struct HasSize<
    T,
    std::void_t<
        decltype(
            std::declval<const T&>()
                .size()
        )
    >
> :
    std::true_type {
};
static_assert(
    HasSize<std::vector<int>>::value
);

static_assert(
    !HasSize<int>::value
);

C++20 이후에는 같은 요구 사항을 개념과 requires 표현식으로 더 직접적으로 작성할 수 있다.

template<class T>
concept HasSize = requires(
    const T& value
) {
    value.size();
};

개념

개념은 템플릿 인자가 충족해야 하는 제약 조건을 이름이 있는 컴파일 시간 술어로 표현한다. 표준은 제약 조건을 템플릿 인자에 대한 요구 사항을 규정하는 논리 연산과 피연산자의 계열로 정의한다.[74]

#include <concepts>

template<class T>
concept Number =
    std::integral<T> ||
    std::floating_point<T>;
template<Number T>
T square(T value)
{
    return value * value;
}
int integer =
    square(12);

double real =
    square(1.5);

// std::string text =
//     square(std::string("C++"));

문자열은 Number를 만족하지 않으므로 함수 후보가 되지 않는다.

표준 개념

<concepts>는 타입 관계와 연산 가능성을 표현하는 기본 개념을 제공한다.

#include <concepts>

static_assert(
    std::same_as<int, int>
);

static_assert(
    std::derived_from<
        Derived,
        Base
    >
);

static_assert(
    std::convertible_to<
        int,
        double
    >
);

static_assert(
    std::integral<int>
);

static_assert(
    std::floating_point<double>
);

호출 가능성과 비교 가능성도 표현할 수 있다.

template<
    class Function,
    class... Arguments
>
requires std::invocable<
    Function,
    Arguments...
>
decltype(auto) invoke_checked(
    Function&& function,
    Arguments&&... arguments
)
{
    return std::invoke(
        std::forward<Function>(
            function
        ),
        std::forward<Arguments>(
            arguments
        )...
    );
}

개념 정의

template<class T>
concept Drawable = requires(
    T& object,
    Renderer& renderer
) {
    object.draw(renderer);
};

반환형을 제한할 수 있다.

template<class T>
concept Measurable = requires(
    const T& object
) {
    {
        object.size()
    } -> std::convertible_to<
        std::size_t
    >;
};

noexcept 여부도 검사할 수 있다.

template<class T>
concept NothrowSwappable =
    requires(T& left, T& right) {
        {
            swap(left, right)
        } noexcept;
    };

단순 요구 사항

template<class T>
concept Incrementable = requires(
    T value
) {
    ++value;
    value++;
};

표현식이 문법적으로 유효해야 한다는 요구 사항이다.

타입 요구 사항

template<class T>
concept Container = requires {
    typename T::value_type;
    typename T::iterator;
};

중첩 타입이 존재하는지 검사한다.

복합 요구 사항

template<class T>
concept RangeLike = requires(
    T& range
) {
    {
        range.begin()
    } -> std::input_or_output_iterator;

    {
        range.end()
    };
};

복합 요구 사항은 표현식의 유효성, noexcept 여부와 결과 타입 제약을 함께 표현할 수 있다.

중첩 요구 사항

template<class T>
concept SmallValue = requires {
    requires sizeof(T) <= 16;
    requires std::is_trivially_copyable_v<T>;
};

다른 상수 조건이나 개념을 내부에서 요구한다.

requires

템플릿 선언에 requires 절을 붙여 제약 조건을 지정할 수 있다.

template<class T>
T average(
    T left,
    T right
)
    requires std::floating_point<T>
{
    return (
        left + right
    ) / T(2);
}

템플릿 매개변수 목록 뒤에 배치할 수도 있다.

template<class T>
requires std::integral<T>
T bit_count(T value)
{
    return implementation(value);
}

축약 형식으로 개념을 템플릿 매개변수에 직접 적용할 수 있다.

template<std::integral T>
T double_value(T value)
{
    return value * 2;
}

자리표시자 타입에도 적용할 수 있다.

std::integral auto count = 42;

std::floating_point auto ratio =
    0.5;

함수 매개변수에 사용하면 축약 함수 템플릿이 된다.

auto add(
    std::integral auto left,
    std::integral auto right
)
{
    return left + right;
}

개념적으로 서로 다른 템플릿 매개변수를 가진 함수 템플릿과 유사하다.

template<
    std::integral Left,
    std::integral Right
>
auto add(
    Left left,
    Right right
)
{
    return left + right;
}

제약된 오버로딩

개념과 제약 조건은 함수가 호출 가능한지를 검사하는 것뿐 아니라 오버로드 후보의 우선순위를 결정하는 데 참여한다.

template<class T>
std::string describe(
    const T&
)
{
    return "value";
}

template<std::integral T>
std::string describe(
    const T&
)
{
    return "integer";
}
std::string first =
    describe(42);

std::string second =
    describe(3.14);

정수 인자에는 더 구체적으로 제약된 오버로드가 선택된다.

제약 관계를 계층적으로 구성할 수 있다.

template<class T>
concept Number =
    std::integral<T> ||
    std::floating_point<T>;

template<class T>
concept SignedNumber =
    Number<T> &&
    std::signed_integral<T>;

다만 std::signed_integral은 부호 있는 정수에 한정되므로 이름과 실제 범위를 일치시키려면 별도 개념을 사용할 수 있다.

template<class T>
concept Arithmetic =
    std::integral<T> ||
    std::floating_point<T>;

template<class T>
concept SignedArithmetic =
    Arithmetic<T> &&
    std::is_signed_v<T>;
template<Arithmetic T>
void process(T value)
{
    process_arithmetic(value);
}

template<SignedArithmetic T>
void process(T value)
{
    process_signed(value);
}

int에는 두 번째 오버로드가 더 구체적이고, unsigned int에는 첫 번째 오버로드만 적용된다.

템플릿과 정적 다형성

템플릿은 서로 다른 타입이 같은 상속 계층에 속하지 않아도 필요한 연산을 제공하면 같은 알고리즘에 사용할 수 있게 한다.

template<class Renderer>
void render_frame(
    Renderer& renderer
)
{
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
}
VulkanRenderer vulkan;
D3D12Renderer d3d12;
SoftwareRenderer software;

render_frame(vulkan);
render_frame(d3d12);
render_frame(software);

각 호출 대상은 컴파일 시점에 결정된다. 가상 함수와 기반 클래스 포인터가 필요하지 않다.

개념으로 요구 인터페이스를 표현할 수 있다.

template<class T>
concept Renderer = requires(
    T& renderer
) {
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
};
template<Renderer T>
void render_frame(T& renderer)
{
    renderer.begin_frame();
    renderer.draw_world();
    renderer.end_frame();
}

정적 다형성은 호출 타입이 컴파일 시점에 알려지고 각 타입에 맞는 코드 생성이 허용되는 구조에 적합하다.

CRTP

CRTP는 파생 클래스가 자신을 템플릿 인자로 기반 클래스에 전달하는 패턴이다.

template<class Derived>
class Printable {
public:
    void print() const
    {
        const Derived& derived =
            static_cast<
                const Derived&
            >(*this);

        derived.print_impl();
    }
};
class User :
    public Printable<User> {
    std::string name_;

public:
    explicit User(std::string name)
        : name_(std::move(name))
    {
    }

    void print_impl() const
    {
        print_text(name_);
    }
};
User user("Luna");

user.print();

가상 함수 없이 기반 클래스에서 파생 클래스의 구현을 호출한다.

CRTP는 다음과 같은 구조에 사용될 수 있다.

  • 정적 인터페이스
  • 연산자 공통 구현
  • 믹스인
  • 인스턴스 계수
  • 컴파일 시간 정책 주입
template<class Derived>
class InstanceCounter {
    inline static
        std::size_t count_ = 0;

protected:
    InstanceCounter()
    {
        ++count_;
    }

    InstanceCounter(
        const InstanceCounter&
    )
    {
        ++count_;
    }

    ~InstanceCounter()
    {
        --count_;
    }

public:
    static std::size_t count() noexcept
    {
        return count_;
    }
};
class Texture :
    public InstanceCounter<Texture> {
};

class Mesh :
    public InstanceCounter<Mesh> {
};

각 파생 타입은 별도의 개수 값을 가진다.

현대 C++에서는 단순한 정적 인터페이스 요구만 필요하다면 CRTP 대신 개념과 자유 함수를 사용할 수도 있다.

정책 기반 설계

정책 기반 설계는 클래스의 일부 동작을 템플릿 인자로 분리해 컴파일 시점에 조합한다.

struct NoLock {
    void lock() noexcept
    {
    }

    void unlock() noexcept
    {
    }
};

class MutexLock {
    std::mutex mutex_;

public:
    void lock()
    {
        mutex_.lock();
    }

    void unlock()
    {
        mutex_.unlock();
    }
};
template<class LockPolicy>
class Queue {
    LockPolicy lock_;
    std::deque<Task> tasks_;

public:
    void push(Task task)
    {
        std::lock_guard guard(lock_);

        tasks_.push_back(
            std::move(task)
        );
    }
};
Queue<NoLock> local_queue;
Queue<MutexLock> shared_queue;

같은 큐 구현에 서로 다른 동기화 정책을 적용한다.

정책에 필요한 인터페이스를 개념으로 제한할 수 있다.

template<class T>
concept BasicLockable = requires(
    T& lock
) {
    lock.lock();
    lock.unlock();
};
template<BasicLockable LockPolicy>
class Queue {
};

타입 특성

타입 특성은 템플릿 인자의 분류와 속성을 컴파일 시간에 검사하거나 새로운 타입을 계산하는 템플릿이다.

#include <type_traits>

static_assert(
    std::is_integral_v<int>
);

static_assert(
    std::is_pointer_v<int*>
);

static_assert(
    std::is_reference_v<int&>
);

static_assert(
    std::is_class_v<std::string>
);

타입 관계 검사

class Base {
};

class Derived : public Base {
};

static_assert(
    std::is_same_v<int, int>
);

static_assert(
    std::is_base_of_v<
        Base,
        Derived
    >
);

static_assert(
    std::is_convertible_v<
        Derived*,
        Base*
    >
);

타입 변환

using First =
    std::remove_const_t<
        const int
    >;

using Second =
    std::remove_reference_t<
        int&
    >;

using Third =
    std::add_pointer_t<
        double
    >;
static_assert(
    std::is_same_v<
        First,
        int
    >
);

static_assert(
    std::is_same_v<
        Second,
        int
    >
);

static_assert(
    std::is_same_v<
        Third,
        double*
    >
);

조건부 타입

template<class T>
using EfficientParameter =
    std::conditional_t<
        sizeof(T) <=
            2 * sizeof(void*) &&
        std::is_trivially_copyable_v<T>,
        T,
        const T&
    >;

작고 복사가 가벼운 타입은 값으로, 큰 타입은 const 참조로 받는 타입을 계산한다.

이러한 기준은 실제 ABI와 사용 패턴에 따라 달라질 수 있으므로 모든 인터페이스에 일괄 적용하기보다 측정과 의미를 기준으로 선택할 수 있다.

태그 디스패치

태그 디스패치는 타입 특성에서 얻은 태그 타입을 함수 오버로드에 전달해 구현을 선택하는 방식이다.

struct RandomAccessTag {
};

struct SequentialTag {
};
template<class Iterator>
void advance_impl(
    Iterator& iterator,
    std::ptrdiff_t distance,
    RandomAccessTag
)
{
    iterator += distance;
}

template<class Iterator>
void advance_impl(
    Iterator& iterator,
    std::ptrdiff_t distance,
    SequentialTag
)
{
    while (distance > 0) {
        ++iterator;
        --distance;
    }
}
template<class Iterator>
void advance(
    Iterator& iterator,
    std::ptrdiff_t distance
)
{
    using Category =
        typename Iterator::category;

    advance_impl(
        iterator,
        distance,
        Category {}
    );
}

반복자 종류에 따라 최적의 구현을 선택한다.

현대 C++에서는 if constexpr와 개념을 사용할 수 있다.

template<class Iterator>
void advance(
    Iterator& iterator,
    std::ptrdiff_t distance
)
{
    if constexpr (
        std::random_access_iterator<
            Iterator
        >
    ) {
        iterator += distance;
    }
    else {
        while (distance > 0) {
            ++iterator;
            --distance;
        }
    }
}

if constexpr

if constexpr는 컴파일 시간 조건에 따라 템플릿 인스턴스의 구현 분기를 선택한다.

template<class T>
void print_value(
    const T& value
)
{
    if constexpr (
        std::is_pointer_v<T>
    ) {
        if (value) {
            print(*value);
        }
    }
    else {
        print(value);
    }
}

선택되지 않은 분기는 해당 템플릿 인스턴스에서 버려진다.

int value = 42;

print_value(value);
print_value(&value);

타입별 구현을 하나의 함수 안에 구성할 수 있지만, 조건이 많아지면 제약된 오버로드나 별도 함수로 분리하는 구조가 더 명확할 수 있다.

템플릿 메타프로그래밍

템플릿 메타프로그래밍은 템플릿 인스턴스화와 특수화 규칙을 이용해 컴파일 시간에 값과 타입을 계산한다.

값 계산

template<unsigned int Value>
struct Factorial {
    static constexpr
        unsigned long long result =
            Value *
            Factorial<
                Value - 1
            >::result;
};

template<>
struct Factorial<0> {
    static constexpr
        unsigned long long result =
            1;
};
static_assert(
    Factorial<5>::result == 120
);

현대 C++에서는 같은 계산을 constexpr 함수로 더 직접적으로 작성할 수 있다.

constexpr unsigned long long
factorial(unsigned int value)
{
    unsigned long long result = 1;

    for (unsigned int current = 2;
         current <= value;
         ++current) {
        result *= current;
    }

    return result;
}
static_assert(
    factorial(5) == 120
);

타입 목록

template<class... Types>
struct TypeList {
};

첫 번째 타입을 얻는 특성을 만들 수 있다.

template<class List>
struct Front;

template<
    class First,
    class... Rest
>
struct Front<
    TypeList<
        First,
        Rest...
    >
> {
    using type = First;
};
using Types =
    TypeList<
        int,
        double,
        std::string
    >;

using First =
    Front<Types>::type;

static_assert(
    std::is_same_v<
        First,
        int
    >
);

타입 필터링

template<
    template<class> class Predicate,
    class List
>
struct Filter;

복잡한 타입 목록 변환은 직렬화 시스템, ECS 구성 요소 목록, 함수 서명 분석과 정적 등록 구조 등에 활용될 수 있다.

현대 C++에서는 템플릿 메타프로그래밍만으로 모든 계산을 수행하기보다 다음 기능을 함께 사용한다.

  • constexpr 함수
  • consteval 함수
  • if constexpr
  • 개념과 requires
  • 타입 특성
  • 가변 인자 템플릿
  • 폴드 표현식

표준 라이브러리의 제네릭 설계

C++ 표준 라이브러리의 컨테이너, 반복자, 알고리즘, 범위와 함수 객체는 제네릭 프로그래밍을 중심으로 설계되어 있다. 표준 문서는 컨테이너·반복자·알고리즘이 인터페이스 규약을 충족하거나 개념을 모델링하는 구조를 사용한다.[75]

컨테이너와 원소 타입

std::vector<int> integers;

std::vector<std::string> strings;

std::unordered_map<
    std::string,
    Texture
> textures;

같은 컨테이너 템플릿이 서로 다른 원소와 키 타입에 사용된다.

반복자 기반 알고리즘

template<
    class Iterator,
    class Value
>
Iterator find_value(
    Iterator first,
    Iterator last,
    const Value& value
)
{
    while (first != last) {
        if (*first == value) {
            return first;
        }

        ++first;
    }

    return last;
}

알고리즘은 컨테이너 자체가 아니라 반복자가 제공하는 연산에 의존한다.

std::vector<int> vector {
    1,
    2,
    3
};

std::list<int> list {
    1,
    2,
    3
};

auto vector_result =
    find_value(
        vector.begin(),
        vector.end(),
        2
    );

auto list_result =
    find_value(
        list.begin(),
        list.end(),
        2
    );

비교 정책

std::sort(
    users.begin(),
    users.end(),
    [](const User& left,
       const User& right) {
        return left.level >
               right.level;
    }
);

알고리즘과 비교 기준이 분리된다.

범위 알고리즘

std::ranges::sort(
    users,
    std::greater {},
    &User::level
);

범위 전체와 비교 함수, 프로젝션을 각각 전달할 수 있다.

개념 기반 요구 사항

표준 개념은 알고리즘 인자가 만족해야 하는 요구 사항을 명시한다.

template<
    std::ranges::input_range Range,
    class Function
>
requires std::invocable<
    Function&,
    std::ranges::range_reference_t<
        Range
    >
>
void apply(
    Range&& range,
    Function function
)
{
    for (auto&& value : range) {
        std::invoke(
            function,
            value
        );
    }
}

제네릭 사용자 정의 지점

제네릭 라이브러리는 사용자 정의 타입이 특정 동작을 제공할 수 있는 확장 지점을 설계해야 한다.

멤버 함수

template<class T>
void serialize(
    Writer& writer,
    const T& value
)
{
    value.serialize(writer);
}

타입이 멤버 함수를 제공해야 한다.

자유 함수와 ADL

template<class T>
void serialize_value(
    Writer& writer,
    const T& value
)
{
    serialize(writer, value);
}

사용자 정의 타입과 같은 이름공간의 자유 함수를 ADL로 찾을 수 있다.

namespace game {
    struct Position {
        float x;
        float y;
    };

    void serialize(
        Writer& writer,
        const Position& position
    )
    {
        writer.write(position.x);
        writer.write(position.y);
    }
}

특성 특수화

template<class T>
struct SerializationTraits;

template<>
struct SerializationTraits<
    game::Position
> {
    static void write(
        Writer& writer,
        const game::Position& position
    )
    {
        writer.write(position.x);
        writer.write(position.y);
    }
};

사용자 정의 지점 객체

함수 객체와 tag_invoke 형태 같은 구조를 사용해 우선순위와 기본 구현을 제어할 수 있다.

struct Draw {
    template<class T>
    void operator()(
        Renderer& renderer,
        const T& object
    ) const
        noexcept(
            noexcept(
                draw(
                    renderer,
                    object
                )
            )
        )
    {
        draw(renderer, object);
    }
};

inline constexpr Draw draw_object {};

사용자 정의 지점의 구체적인 설계는 라이브러리의 확장 범위, ADL 사용 여부와 오류 메시지 등을 고려해야 한다.

코드 생성과 실행 비용

템플릿은 각 템플릿 인자 조합에 맞는 코드를 만들 수 있으므로 호출 대상과 데이터 배치를 컴파일 시점에 확정하고 인라인화와 상수 전파를 적용하기 쉽다.

template<class Operation>
int transform(
    int value,
    Operation operation
)
{
    return operation(value);
}
int result = transform(
    21,
    [](int value) {
        return value * 2;
    }
);

람다 타입과 구현이 호출 지점에서 알려져 있으므로 컴파일러는 호출 경계를 제거하고 결과를 직접 계산할 수 있다.

반면 많은 타입과 정책 조합을 인스턴스화하면 생성 코드와 빌드 시간이 증가할 수 있다.

process<int>();
process<float>();
process<double>();
process<Vector2>();
process<Vector3>();
process<Matrix4>();

각 특수화가 실제로 다른 기계어를 필요로 하지 않더라도 목적 파일에 중복 코드가 생길 수 있다. 컴파일러와 링커는 동일 코드 접기와 약한 심볼 병합 등을 적용할 수 있지만 결과는 구현과 빌드 설정에 따라 달라진다.

공통 동작을 비템플릿 기반 구현으로 분리할 수 있다.

void process_bytes_impl(
    std::span<const std::byte> data
);

template<class T>
void process_object(
    const T& object
)
{
    auto bytes =
        std::as_bytes(
            std::span(
                &object,
                1
            )
        );

    process_bytes_impl(bytes);
}

타입별 템플릿 코드는 얇게 유지하고 큰 공통 처리는 하나의 비템플릿 함수에 둔다.

템플릿과 ABI

템플릿 특수화의 구체적인 타입과 심볼 이름은 컴파일러 ABI와 빌드 설정의 영향을 받는다.

template<class T>
class PublicContainer {
};

템플릿을 공유 라이브러리의 공개 ABI에 직접 노출하면 다음 요소가 ABI에 포함될 수 있다.

  • 템플릿 인자 타입
  • 컨테이너 구현
  • 할당자 타입
  • 인라인 함수 본문
  • 컴파일러의 이름 장식
  • 표준 라이브러리 구현
  • 컴파일 옵션과 구조체 배치

안정적인 바이너리 경계가 필요하면 비템플릿 인터페이스나 PImpl, C ABI, 타입 소거를 사용할 수 있다.

class Renderer {
    class Impl;

    std::unique_ptr<Impl> impl_;

public:
    Renderer();
    ~Renderer();

    void draw(
        std::span<const Command> commands
    );
};

내부 구현에서는 템플릿을 자유롭게 사용할 수 있다.

class Renderer::Impl {
    std::vector<Command> commands_;
    std::unordered_map<
        ResourceId,
        Resource
    > resources_;
};

템플릿과 타입 소거

템플릿 기반 정적 다형성은 구체 타입이 컴파일 시점에 알려져야 한다. 서로 다른 타입을 하나의 동일한 런타임 타입으로 저장해야 한다면 타입 소거를 사용할 수 있다.

#include <functional>

std::vector<
    std::function<void()>
> tasks;

tasks.emplace_back(
    [] {
        render();
    }
);

tasks.emplace_back(
    [value = 42] {
        process(value);
    }
);

서로 다른 람다 타입이 모두 std::function<void()>로 저장된다.

사용자 정의 타입 소거를 만들 수도 있다.

class Drawable {
    struct Concept {
        virtual void draw(
            Renderer&
        ) const = 0;

        virtual std::unique_ptr<
            Concept
        > clone() const = 0;

        virtual ~Concept() = default;
    };

    template<class T>
    struct Model final :
        Concept {
        T object;

        explicit Model(T value)
            : object(
                  std::move(value)
              )
        {
        }

        void draw(
            Renderer& renderer
        ) const override
        {
            object.draw(renderer);
        }

        std::unique_ptr<Concept>
        clone() const override
        {
            return std::make_unique<
                Model
            >(object);
        }
    };

    std::unique_ptr<Concept> object_;

public:
    template<class T>
    Drawable(T value)
        : object_(
              std::make_unique<
                  Model<T>
              >(
                  std::move(value)
              )
          )
    {
    }

    void draw(
        Renderer& renderer
    ) const
    {
        object_->draw(renderer);
    }
};

외부에서는 구체 타입을 알지 않고 하나의 Drawable 값으로 사용한다.

std::vector<Drawable> objects;

objects.emplace_back(
    Circle(10.0)
);

objects.emplace_back(
    Rectangle(20.0, 30.0)
);

for (const Drawable& object : objects) {
    object.draw(renderer);
}

템플릿은 내부 모델을 생성하는 데 사용되고, 외부 인터페이스는 런타임 다형성을 제공한다.

오류 진단

제약되지 않은 템플릿은 본문에서 실제로 사용한 연산이 실패한 지점에서 긴 오류 메시지를 만들 수 있다.

template<class T>
void sort_values(T& values)
{
    std::sort(
        values.begin(),
        values.end()
    );
}

begin, end 또는 반복자 요구 사항이 없는 타입을 전달하면 여러 템플릿 내부 오류가 발생할 수 있다.

개념으로 요구 사항을 선언부에 배치할 수 있다.

template<
    std::ranges::random_access_range Range
>
requires std::sortable<
    std::ranges::iterator_t<Range>
>
void sort_values(
    Range& values
)
{
    std::ranges::sort(values);
}

호출 인자가 요구 사항을 만족하지 않으면 제약 조건 실패를 중심으로 진단할 수 있다.

template<class T>
class BinaryPacket {
    static_assert(
        std::is_trivially_copyable_v<T>,
        "BinaryPacket의 T는 trivially copyable 타입이어야 한다."
    );
};

static_assert는 템플릿이 요구하는 조건을 별도의 메시지와 함께 검사할 수 있다.

제네릭 인터페이스 설계

제네릭 함수는 특정 구현 타입보다 필요한 동작을 기준으로 매개변수를 설계할 수 있다.

구체 컨테이너에 종속된 함수

void print_values(
    const std::vector<int>& values
)
{
    for (int value : values) {
        print(value);
    }
}

이 함수는 std::vector<int>에만 직접 적용된다.

범위 기반 제네릭 함수

template<std::ranges::input_range Range>
void print_values(
    Range&& values
)
{
    for (auto&& value : values) {
        print(value);
    }
}

벡터, 리스트, 배열과 뷰에 적용할 수 있다.

std::vector<int> vector {
    1,
    2,
    3
};

std::list<int> list {
    4,
    5,
    6
};

std::array<int, 3> array {
    7,
    8,
    9
};

print_values(vector);
print_values(list);
print_values(array);

지나치게 넓은 요구 사항

함수가 실제로 읽기 순회만 필요하다면 임의 접근 반복자를 요구할 필요가 없다.

template<
    std::ranges::random_access_range Range
>
void print_values(
    Range&& range
)
{
    for (auto&& value : range) {
        print(value);
    }
}

필요한 최소 요구 사항인 input_range로 표현하면 더 많은 타입을 허용한다.

template<
    std::ranges::input_range Range
>
void print_values(
    Range&& range
)
{
    for (auto&& value : range) {
        print(value);
    }
}

의미적 요구 사항

문법적으로 연산자가 존재하는 것만으로 알고리즘의 의미가 항상 올바른 것은 아니다.

정렬 비교 함수는 일반적으로 엄격 약순서를 제공해야 한다.

auto invalid_compare = [](
    int left,
    int right
) {
    return left <= right;
};

같은 값에 대해 compare(value, value)true가 되므로 정렬 알고리즘이 요구하는 관계를 만족하지 않는다.

auto compare = [](
    int left,
    int right
) {
    return left < right;
};

개념은 많은 문법적·타입적 요구를 검사할 수 있지만 모든 의미적 계약을 자동으로 증명하지는 않는다.

템플릿 활용 구조

템플릿은 여러 수준에서 사용할 수 있다.

목적주요 구조
여러 타입에 같은 함수 적용함수 템플릿
원소 타입이 다른 자료구조클래스 템플릿
컴파일 시간 크기와 정책상수 템플릿 매개변수
컨테이너 또는 정책 교체템플릿 템플릿 매개변수
타입별 상수변수 템플릿
복잡한 타입 단순화별칭 템플릿
특정 타입 구현 교체명시적 특수화
타입 계열 구현 교체부분 특수화
가변 개수 타입·인자매개변수 팩
팩에 연산 적용폴드 표현식
타입에서 인자 추론함수 템플릿 인자 추론
생성자에서 클래스 타입 추론CTAD와 추론 가이드
템플릿 요구 사항 명시개념과 requires
컴파일 시간 분기if constexpr
정적 인터페이스개념, 템플릿, CRTP
실행 시간 단일 타입타입 소거
타입과 값 계산타입 특성, constexpr, 메타프로그래밍

종합 예제

다음 예제는 개념, 범위, 투영 함수, 전달 참조와 제약된 오버로드를 이용해 여러 데이터 집합에서 최댓값을 찾는다.

#include <concepts>
#include <functional>
#include <optional>
#include <ranges>
#include <utility>

template<
    std::ranges::input_range Range,
    class Projection =
        std::identity,
    class Compare =
        std::ranges::less
>
requires
    std::indirect_strict_weak_order<
        Compare,
        std::projected<
            std::ranges::iterator_t<Range>,
            Projection
        >
    >
auto maximum_element_value(
    Range&& range,
    Compare compare = {},
    Projection projection = {}
)
    -> std::optional<
        std::ranges::range_value_t<
            Range
        >
    >
{
    auto iterator =
        std::ranges::begin(range);

    const auto end =
        std::ranges::end(range);

    if (iterator == end) {
        return std::nullopt;
    }

    auto result = *iterator;

    ++iterator;

    for (; iterator != end; ++iterator) {
        if (std::invoke(
                compare,
                std::invoke(
                    projection,
                    result
                ),
                std::invoke(
                    projection,
                    *iterator
                )
            )) {
            result = *iterator;
        }
    }

    return result;
}

정수 범위에는 기본 비교와 투영을 사용한다.

std::vector<int> values {
    10,
    50,
    20,
    40
};

auto maximum =
    maximum_element_value(
        values
    );

사용자 정의 타입에서는 멤버 포인터를 투영으로 전달할 수 있다.

struct User {
    std::string name;
    int level;
};

std::vector<User> users {
    {"A", 10},
    {"B", 30},
    {"C", 20}
};

auto highest_level =
    maximum_element_value(
        users,
        std::ranges::less {},
        &User::level
    );

이 함수는 다음과 같은 제네릭 구조를 가진다.

  • Range는 입력 범위여야 한다.
  • Projection은 원소에서 비교할 값을 추출한다.
  • Compare는 투영 결과에 엄격 약순서를 제공해야 한다.
  • 빈 범위는 std::nullopt로 표현한다.
  • 실제 컨테이너 타입과 원소 타입은 함수에 고정되지 않는다.
  • 멤버 포인터, 람다와 함수 객체를 같은 투영 인터페이스로 사용할 수 있다.
  • 요구 사항은 requires 절을 통해 함수 선언에 나타난다.

C++의 템플릿은 단순히 여러 타입에 같은 코드를 복사하는 기능이 아니다. 템플릿 인자 추론, 오버로드 결정, 특수화, 매개변수 팩, 타입 특성, 개념과 컴파일 시간 평가를 결합해 자료구조와 알고리즘의 요구 사항을 타입 시스템 안에 표현한다.

제네릭 프로그래밍에서는 알고리즘을 구체적인 타입 이름보다 필요한 연산과 의미적 규약을 중심으로 작성한다. 이를 통해 표준 컨테이너와 사용자 정의 타입, 정적 배열, 반복자와 지연 범위가 같은 알고리즘에 참여할 수 있다. 필요한 타입이 컴파일 시점에 알려진다면 호출과 정책 선택을 정적으로 결정할 수 있고, 런타임에 타입을 통합해야 한다면 템플릿 내부 구현을 타입 소거 또는 가상 인터페이스와 결합할 수 있다.

표준 라이브러리

C++ 표준 라이브러리는 C++ 프로그램이 공통적으로 필요로 하는 자료구조, 알고리즘, 문자열 처리, 입출력, 파일 시스템, 시간 계산, 메모리 관리, 동시성, 수치 계산과 오류 표현 기능을 표준화한 라이브러리다. 언어 문법과 별개로 배포되는 특정 회사의 라이브러리가 아니라, C++ 표준 자체가 요구하는 인터페이스와 동작의 집합이다.

현재 정식 C++ 표준은 C++23으로 불리는 ISO/IEC 14882:2024이며, 표준 라이브러리의 구성 요소도 이 표준에 포함된다.[76] 실제 구현은 GCC의 libstdc++, Clang과 LLVM의 libc++, Microsoft Visual C++의 Microsoft C++ Standard Library 등으로 제공된다. 같은 표준을 구현하더라도 지원 완료 시점, 확장 기능, ABI와 성능 특성은 구현체별로 달라질 수 있다.

표준 라이브러리는 대부분 std 이름공간 안에 선언된다.

#include <string>
#include <vector>

std::string language = "C++";

std::vector<int> versions {
    98,
    11,
    14,
    17,
    20,
    23
};

표준 라이브러리는 하나의 거대한 단일 라이브러리라기보다 서로 연결되는 구성 요소의 집합이다. 컨테이너가 데이터를 소유하고, 반복자와 범위가 데이터에 접근하는 공통 인터페이스를 제공하며, 알고리즘이 그 인터페이스를 통해 데이터를 처리한다. 할당자와 메모리 자원은 저장 공간 확보 방식을 제어하고, 함수 객체와 개념은 알고리즘의 동작과 요구 사항을 매개변수화한다.

C++ 표준 라이브러리의 주요 구성
  • C++ 표준 라이브러리
    • 언어 지원
      • 타입 정보와 타입 특성
      • 예외와 종료 처리
      • 초기화와 객체 수명 지원
      • 코루틴 지원
    • 일반 유틸리티
      • std::pair
      • std::tuple
      • std::optional
      • std::variant
      • std::expected
      • 함수 객체와 호출 도구
    • 컨테이너
      • 순차 컨테이너
      • 연관 컨테이너
      • 비정렬 연관 컨테이너
      • 컨테이너 어댑터
      • 평면 연관 컨테이너
    • 반복자와 범위
      • 반복자
      • 범위
      • 범위 어댑터
    • 알고리즘
      • 검색과 검사
      • 복사와 변환
      • 정렬과 집합 연산
      • 수치 알고리즘
      • 병렬 알고리즘
    • 문자열과 텍스트
      • std::basic_string
      • std::basic_string_view
      • 문자 변환
      • 정규 표현식
      • 서식 지정과 출력
    • 입출력과 파일 시스템
      • 스트림
      • 파일 스트림
      • 문자열 스트림
      • 스팬 스트림
      • 파일 시스템
    • 시간과 난수
      • std::chrono
      • 달력과 시간대
      • 난수 엔진과 분포
    • 메모리 관리
      • 스마트 포인터
      • 할당자
      • 다형적 메모리 자원
      • 객체 생성과 파괴 도구
    • 동시성과 비동기 실행
      • 스레드
      • 뮤텍스
      • 조건 변수
      • 원자적 연산
      • 퓨처와 비동기 작업
    • 수치 계산
      • 복소수
      • 수치 배열
      • 수학 함수
      • 다차원 뷰
      • 수치 알고리즘

헤더와 이름공간

전통적인 C++ 표준 라이브러리는 필요한 선언을 표준 헤더로 포함해 사용한다.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

각 헤더는 관련된 타입과 함수를 선언한다.

헤더주요 기능
<vector>동적 연속 배열
<string>소유 문자열
<string_view>비소유 문자열 뷰
<algorithm>정렬, 검색, 복사와 변환 알고리즘
<ranges>범위, 뷰와 범위 어댑터
<memory>스마트 포인터와 메모리 지원
<filesystem>경로와 파일 시스템 작업
<chrono>시간 간격, 시점, 달력과 시간대
<thread>스레드
<mutex>뮤텍스와 잠금 객체
<atomic>원자적 연산
<format>형식 지정 문자열
<print>C++23 형식 지정 출력
<expected>성공값 또는 오류값
<mdspan>다차원 비소유 데이터 뷰

전처리기 기반 헤더와 함께 C++23은 표준 라이브러리를 가져오기 위한 표준 명명 모듈 std와 호환성용 std.compat를 규정한다.[77]

import std;

int main()
{
    std::vector<int> values {
        3,
        1,
        2
    };

    std::ranges::sort(values);

    std::println(
        "{} {} {}",
        values[0],
        values[1],
        values[2]
    );
}

표준 모듈의 실제 지원 여부와 빌드 방법은 컴파일러와 빌드 시스템에 따라 달라질 수 있다.

컨테이너

컨테이너는 객체 집합을 저장하고 그 원소에 접근하는 자료구조다. 대부분의 표준 컨테이너는 원소 타입을 템플릿 인자로 받으며, 원소 생성·이동·파괴와 저장 공간 관리를 수행한다.[78]

컨테이너는 크게 다음과 같이 나뉜다.

  • 순차 컨테이너
  • 정렬 연관 컨테이너
  • 비정렬 연관 컨테이너
  • 컨테이너 어댑터
  • 평면 연관 컨테이너
  • 고정 크기 배열

std::array

std::array는 크기가 컴파일 시점에 결정되는 고정 크기 배열을 값 타입으로 제공한다.

#include <array>

std::array<int, 4> values {
    10,
    20,
    30,
    40
};

static_assert(
    values.size() == 4
);

내장 배열과 달리 복사와 대입, 반복자와 size() 등의 컨테이너 인터페이스를 제공한다.

std::array<int, 3> first {
    1,
    2,
    3
};

std::array<int, 3> second =
    first;

크기가 타입의 일부이므로 크기가 다른 std::array는 서로 다른 타입이다.

static_assert(
    !std::is_same_v<
        std::array<int, 3>,
        std::array<int, 4>
    >
);

std::vector

std::vector는 동적으로 크기가 변하는 연속 배열이다. 원소는 일반적으로 메모리에 연속해서 배치되며, 임의 접근과 캐시 지역성이 필요한 범용 컨테이너로 널리 사용된다.

#include <vector>

std::vector<int> values;

values.push_back(10);
values.push_back(20);
values.push_back(30);
for (int value : values) {
    process(value);
}

size()는 현재 원소 수, capacity()는 재할당 없이 저장할 수 있는 원소 수를 나타낸다.

std::vector<int> values;

values.reserve(100);

for (int value = 0;
     value < 100;
     ++value) {
    values.push_back(value);
}

reserve()는 예상 크기에 맞춰 저장 공간을 미리 확보해 반복적인 재할당을 줄일 수 있다.

벡터가 재할당되면 기존 원소를 가리키는 포인터, 참조와 반복자가 무효화될 수 있다.

std::vector<int> values {
    10
};

int* pointer = &values[0];

values.push_back(20);

// 재할당이 발생했다면 pointer는 무효다.

emplace_back()은 전달된 인자로 컨테이너 내부에서 원소를 생성한다.

std::vector<User> users;

users.emplace_back(
    "Luna",
    20
);

이미 완성된 객체가 있다면 push_back()으로 이동할 수 있다.

User user(
    "Luna",
    20
);

users.push_back(
    std::move(user)
);

std::deque

std::deque는 양쪽 끝에서 효율적으로 원소를 추가하고 제거할 수 있는 순차 컨테이너다.

#include <deque>

std::deque<int> values;

values.push_back(20);
values.push_front(10);
values.push_back(30);

std::vector처럼 임의 접근을 지원하지만 전체 저장 공간이 하나의 연속 배열이라는 보장은 없다.

int first = values[0];
int second = values[1];

앞쪽 삽입이 빈번하면서 임의 접근도 필요할 때 사용할 수 있다.

std::list

std::list는 이중 연결 리스트다.

#include <list>

std::list<int> values {
    10,
    20,
    30
};

auto iterator = values.begin();

++iterator;

values.insert(
    iterator,
    15
);

삽입 지점의 반복자를 알고 있다면 원소 이동 없이 노드를 연결할 수 있다. 그러나 원소마다 포인터와 개별 노드 저장 공간이 필요하고, 연속 메모리 순회에 비해 캐시 지역성이 낮을 수 있다.

std::forward_list

std::forward_list는 단방향 연결 리스트다.

#include <forward_list>

std::forward_list<int> values {
    10,
    20,
    30
};

values.push_front(5);

이전 노드를 직접 추적하지 않으므로 insert_after()erase_after() 형태의 인터페이스를 사용한다.

정렬 연관 컨테이너

std::set, std::multiset, std::map, std::multimap은 키의 정렬 관계를 유지하는 연관 컨테이너다.

#include <map>
#include <string>

std::map<std::string, int> versions {
    {"C++98", 1998},
    {"C++11", 2011},
    {"C++20", 2020},
    {"C++23", 2023}
};
auto iterator =
    versions.find("C++20");

if (iterator != versions.end()) {
    print(iterator->second);
}

일반적으로 균형 트리와 유사한 구조로 구현되며 탐색, 삽입과 삭제에 로그 시간 복잡도를 제공한다.

std::set은 키 자체를 원소로 저장한다.

#include <set>

std::set<int> identifiers {
    3,
    1,
    2,
    2
};

// 중복되지 않은 1, 2, 3이 저장된다.

multisetmultimap은 같은 키의 중복을 허용한다.

비정렬 연관 컨테이너

std::unordered_set, std::unordered_multiset, std::unordered_map, std::unordered_multimap은 해시 기반 연관 컨테이너다.

#include <string>
#include <unordered_map>

std::unordered_map<
    std::string,
    Texture
> textures;

textures.emplace(
    "player",
    load_texture("player.png")
);

평균적인 탐색과 삽입은 상수 시간에 가까울 수 있지만 해시 품질, 버킷 수와 충돌 분포에 영향을 받는다.

사용자 정의 키는 동등 비교와 해시 함수를 제공해야 한다.

struct ResourceId {
    std::uint64_t high;
    std::uint64_t low;

    bool operator==(
        const ResourceId&
    ) const = default;
};

struct ResourceIdHash {
    std::size_t operator()(
        const ResourceId& id
    ) const noexcept
    {
        std::size_t first =
            std::hash<std::uint64_t> {}(
                id.high
            );

        std::size_t second =
            std::hash<std::uint64_t> {}(
                id.low
            );

        return first ^
               (
                   second +
                   0x9e3779b9 +
                   (first << 6) +
                   (first >> 2)
               );
    }
};
std::unordered_map<
    ResourceId,
    Resource,
    ResourceIdHash
> resources;

평면 연관 컨테이너

C++23의 std::flat_map, std::flat_multimap, std::flat_setstd::flat_multiset은 정렬된 연속 컨테이너를 기반으로 연관 컨테이너 인터페이스를 제공한다.[79]

#include <flat_map>
#include <string>

std::flat_map<std::string, int>
    versions {
        {"C++11", 2011},
        {"C++20", 2020},
        {"C++23", 2023}
    };

연속 저장 구조는 순회와 검색에서 좋은 캐시 지역성을 제공할 수 있지만, 중간 삽입과 삭제에서는 여러 원소를 이동해야 할 수 있다.

컨테이너 어댑터

컨테이너 어댑터는 기존 순차 컨테이너 위에 제한된 자료구조 인터페이스를 제공한다.

##### std::stack

#include <stack>

std::stack<int> stack;

stack.push(10);
stack.push(20);

int value = stack.top();

stack.pop();

마지막에 넣은 원소를 먼저 꺼내는 LIFO 구조다.

##### std::queue

#include <queue>

std::queue<Task> tasks;

tasks.push(first_task);
tasks.push(second_task);

Task next =
    std::move(tasks.front());

tasks.pop();

먼저 넣은 원소를 먼저 꺼내는 FIFO 구조다.

##### std::priority_queue

#include <queue>
#include <vector>

std::priority_queue<int> values;

values.push(10);
values.push(50);
values.push(20);

int maximum = values.top();

비교 기준에 따라 우선순위가 가장 높은 원소에 접근한다.

struct TaskCompare {
    bool operator()(
        const Task& left,
        const Task& right
    ) const
    {
        return left.priority <
               right.priority;
    }
};

std::priority_queue<
    Task,
    std::vector<Task>,
    TaskCompare
> tasks;

컨테이너 선택

컨테이너는 이름보다 데이터 배치와 작업 패턴을 기준으로 선택할 수 있다.

요구 사항일반적인 선택
고정 크기 연속 배열std::array
가변 크기 연속 배열std::vector
양쪽 끝 삽입·삭제std::deque
노드 단위 연결과 안정적 원소 주소std::list
정렬된 고유 키std::set, std::map
평균적으로 빠른 키 탐색std::unordered_set, std::unordered_map
정렬 검색과 연속 저장std::flat_set, std::flat_map
LIFOstd::stack
FIFOstd::queue
우선순위 기반 추출std::priority_queue

일반적인 순차 저장에는 std::vector가 직접적인 기본 선택이 될 수 있지만, 삽입·삭제 위치, 주소 안정성, 정렬 유지 여부와 탐색 패턴에 따라 다른 컨테이너가 적합할 수 있다.

반복자

반복자는 컨테이너나 범위의 원소를 순회하고 접근하기 위한 일반화된 위치 표현이다. 포인터와 비슷한 연산을 제공할 수 있지만, 구체적인 타입과 지원 연산은 반복자 종류에 따라 다르다.[80]

std::vector<int> values {
    10,
    20,
    30
};

auto iterator = values.begin();

int first = *iterator;

++iterator;

int second = *iterator;

컨테이너의 begin()은 첫 번째 원소를 나타내는 반복자를, end()는 마지막 원소 다음 위치를 나타내는 센티널 또는 반복자를 반환한다.

for (auto iterator = values.begin();
     iterator != values.end();
     ++iterator) {
    process(*iterator);
}

반복자 범주

전통적인 반복자 분류와 현대 개념은 지원하는 연산에 따라 반복자를 구분한다.

반복자의 일반적인 기능 관계
  • 입력 반복자
    • 전방 반복자
      • 양방향 반복자
        • 임의 접근 반복자
          • 연속 반복자
  • 출력 반복자
    • 출력 위치에 값을 기록
  • 입력 반복자는 값을 순서대로 읽는다.
  • 출력 반복자는 값을 기록한다.
  • 전방 반복자는 같은 위치를 여러 번 순회할 수 있다.
  • 양방향 반복자는 앞뒤로 이동할 수 있다.
  • 임의 접근 반복자는 일정 거리 이동과 차이 계산을 지원한다.
  • 연속 반복자는 원소가 메모리에 연속 배치됨을 표현한다.
static_assert(
    std::random_access_iterator<
        std::vector<int>::iterator
    >
);

static_assert(
    std::bidirectional_iterator<
        std::list<int>::iterator
    >
);

반복자 어댑터

std::back_inserter는 대입을 컨테이너의 push_back()으로 변환하는 출력 반복자를 만든다.

#include <algorithm>
#include <iterator>
#include <vector>

std::vector<int> source {
    1,
    2,
    3
};

std::vector<int> destination;

std::copy(
    source.begin(),
    source.end(),
    std::back_inserter(destination)
);

역방향 반복자는 컨테이너를 뒤에서 앞으로 순회한다.

for (auto iterator = values.rbegin();
     iterator != values.rend();
     ++iterator) {
    process(*iterator);
}

std::move_iterator는 역참조 결과를 이동 가능한 표현식으로 바꾼다.

std::vector<std::string> source {
    "A",
    "B",
    "C"
};

std::vector<std::string> destination(
    std::make_move_iterator(
        source.begin()
    ),
    std::make_move_iterator(
        source.end()
    )
);

반복자 무효화

컨테이너 수정은 기존 반복자를 무효화할 수 있다. 무효화 규칙은 컨테이너와 연산에 따라 다르다.

std::vector<int> values {
    1,
    2,
    3
};

auto iterator = values.begin();

values.push_back(4);

// 재할당이 발생하면 iterator를 사용할 수 없다.

반복자, 참조와 포인터를 장기간 저장할 때는 컨테이너의 재할당과 삭제 규칙을 함께 고려해야 한다.

범위

범위는 순회 가능한 원소 집합을 일반화한 개념이다. C++20 범위 라이브러리는 beginend의 쌍을 하나의 객체로 다루고, 범위 개념과 알고리즘, 뷰와 어댑터를 제공한다.[81]

#include <ranges>
#include <vector>

std::vector<int> values {
    1,
    2,
    3,
    4
};

static_assert(
    std::ranges::range<
        decltype(values)
    >
);

범위 기반 알고리즘은 반복자 쌍 대신 범위 전체를 받을 수 있다.

#include <algorithm>

std::ranges::sort(values);

뷰는 일반적으로 원소를 소유하지 않고 다른 범위의 일부 또는 변환된 표현을 제공하는 경량 범위다.

auto even_values =
    values
    | std::views::filter(
          [](int value) {
              return value % 2 == 0;
          }
      );

even_values는 새 벡터를 즉시 만들지 않는다. 원소를 순회할 때 필터 조건이 평가된다.

for (int value : even_values) {
    print(value);
}

범위 어댑터 파이프라인

auto result =
    values
    | std::views::filter(
          [](int value) {
              return value % 2 == 0;
          }
      )
    | std::views::transform(
          [](int value) {
              return value * value;
          }
      );
for (int value : result) {
    print(value);
}

실행 결과는 원본 범위의 짝수를 제곱한 값이다.

범위 뷰 파이프라인
  1. 원본 범위에서 원소 요청
  2. 필터 조건 평가
  3. 통과한 원소만 다음 단계로 전달
  4. 변환 함수 적용
  5. 소비자에게 최종 원소 제공

주요 뷰

auto first_ten =
    std::views::iota(0)
    | std::views::take(10);

auto reversed =
    values
    | std::views::reverse;

auto dropped =
    values
    | std::views::drop(2);

auto transformed =
    values
    | std::views::transform(
          [](int value) {
              return value * 2;
          }
      );

C++23은 zip, adjacent, chunk, slide, chunk_by, join_with 등 범위 조합 기능을 확장했다.[82]

std::vector<std::string> names {
    "A",
    "B",
    "C"
};

std::vector<int> scores {
    90,
    80,
    70
};

for (auto&& [name, score] :
     std::views::zip(
         names,
         scores
     )) {
    print(name, score);
}

뷰의 수명

뷰는 원본 범위를 소유하지 않을 수 있으므로 원본 객체가 뷰보다 오래 살아야 한다.

auto create_invalid_view()
{
    std::vector<int> values {
        1,
        2,
        3
    };

    return values
        | std::views::filter(
              [](int value) {
                  return value > 1;
              }
          );
}

함수 종료 후 원본 벡터가 파괴되므로 반환된 뷰는 유효한 원소를 참조하지 않는다.

소유 범위를 반환하거나 원본 데이터를 함께 소유하는 타입으로 구성해야 한다.

알고리즘

표준 알고리즘은 반복자 또는 범위가 제공하는 공통 인터페이스를 통해 데이터를 검색, 검사, 복사, 이동, 변환, 정렬과 집합 처리한다.[83]

알고리즘은 특정 컨테이너 타입에 종속되지 않는다.

std::vector<int> vector {
    3,
    1,
    2
};

std::array<int, 3> array {
    6,
    4,
    5
};

std::ranges::sort(vector);
std::ranges::sort(array);

검색

auto iterator =
    std::ranges::find(
        values,
        20
    );

if (iterator != values.end()) {
    process(*iterator);
}

조건으로 검색할 수 있다.

auto iterator =
    std::ranges::find_if(
        values,
        [](int value) {
            return value < 0;
        }
    );

검사

bool all_positive =
    std::ranges::all_of(
        values,
        [](int value) {
            return value > 0;
        }
    );

bool any_zero =
    std::ranges::any_of(
        values,
        [](int value) {
            return value == 0;
        }
    );

bool none_negative =
    std::ranges::none_of(
        values,
        [](int value) {
            return value < 0;
        }
    );

개수 계산

auto count =
    std::ranges::count(
        values,
        42
    );

auto even_count =
    std::ranges::count_if(
        values,
        [](int value) {
            return value % 2 == 0;
        }
    );

복사와 이동

std::vector<int> destination;

std::ranges::copy(
    source,
    std::back_inserter(
        destination
    )
);
std::vector<std::string> destination;

std::ranges::move(
    source,
    std::back_inserter(
        destination
    )
);

변환

std::vector<int> doubled;

doubled.reserve(
    values.size()
);

std::ranges::transform(
    values,
    std::back_inserter(doubled),
    [](int value) {
        return value * 2;
    }
);

제거

std::removestd::ranges::remove는 일반적으로 컨테이너 자체의 크기를 줄이지 않고 제거되지 않은 원소를 앞쪽으로 이동한다.

values.erase(
    std::remove(
        values.begin(),
        values.end(),
        0
    ),
    values.end()
);

현대 표준에서는 컨테이너용 std::erasestd::erase_if를 사용할 수 있다.

std::erase(
    values,
    0
);

std::erase_if(
    values,
    [](int value) {
        return value < 0;
    }
);

정렬

std::ranges::sort(values);

사용자 정의 타입은 비교 함수나 프로젝션을 사용할 수 있다.

struct User {
    std::string name;
    int level;
};

std::vector<User> users;

std::ranges::sort(
    users,
    std::ranges::greater {},
    &User::level
);

이 코드는 User::level을 기준으로 내림차순 정렬한다.

안정 정렬은 동등한 원소의 기존 상대 순서를 유지한다.

std::stable_sort(
    users.begin(),
    users.end(),
    [](const User& left,
       const User& right) {
        return left.level <
               right.level;
    }
);

부분 정렬과 선택

std::ranges::partial_sort(
    values,
    values.begin() + 10
);

앞쪽 열 개 원소만 정렬된 최솟값 집합으로 만든다.

auto middle =
    values.begin() +
    values.size() / 2;

std::ranges::nth_element(
    values,
    middle
);

middle 위치에 정렬했을 때 들어갈 원소를 배치하며 양쪽 전체를 정렬하지는 않는다.

이진 검색

정렬된 범위에 이진 검색을 적용할 수 있다.

auto iterator =
    std::ranges::lower_bound(
        values,
        42
    );

lower_bound는 값보다 작지 않은 첫 위치를 찾는다.

집합 알고리즘

정렬된 범위에 합집합, 교집합, 차집합과 대칭 차집합을 적용할 수 있다.

std::vector<int> first {
    1,
    2,
    3,
    4
};

std::vector<int> second {
    3,
    4,
    5,
    6
};

std::vector<int> intersection;

std::ranges::set_intersection(
    first,
    second,
    std::back_inserter(
        intersection
    )
);

힙 알고리즘

std::vector<int> values {
    10,
    50,
    20,
    40
};

std::ranges::make_heap(values);

values.push_back(60);

std::ranges::push_heap(values);

std::ranges::pop_heap(values);

int maximum =
    values.back();

values.pop_back();

실행 정책과 병렬 알고리즘

일부 표준 알고리즘은 실행 정책을 받아 순차, 병렬 또는 벡터화 실행 가능성을 표현한다.

#include <execution>
#include <numeric>
#include <vector>

std::vector<double> values(
    1'000'000,
    1.0
);

double result =
    std::reduce(
        std::execution::par,
        values.begin(),
        values.end(),
        0.0
    );

주요 실행 정책은 다음과 같다.

정책의미
std::execution::seq순차 실행
std::execution::par여러 실행 스레드를 사용할 수 있는 병렬 실행
std::execution::par_unseq병렬 및 비순차·벡터화 실행 허용
std::execution::unseq단일 스레드 안에서도 비순차·벡터화 실행 허용

병렬 알고리즘에 전달한 함수는 공유 상태 경쟁, 실행 순서 의존과 동기화 문제를 피하도록 작성해야 한다.

int total = 0;

std::for_each(
    std::execution::par,
    values.begin(),
    values.end(),
    [&](int value) {
        total += value;
    }
);

이 코드는 여러 실행이 total을 동시에 수정할 수 있어 안전하지 않다.

독립 결과를 생성하거나 병렬 축약 알고리즘을 사용할 수 있다.

문자열

std::basic_string은 문자 시퀀스를 소유하고 관리하는 문자열 타입 템플릿이다. 일반적으로 std::stringchar 특수화, std::wstringwchar_t 특수화다.

#include <string>

std::string language = "C++";

language += "23";

문자열은 길이와 저장 공간을 관리하고 연속 문자 데이터에 접근할 수 있다.

const char* data =
    language.data();

std::size_t size =
    language.size();

문자열 결합

std::string first = "C";
std::string second = "++";

std::string result =
    first + second;

반복적인 결합에서는 예상 크기를 예약할 수 있다.

std::string result;

result.reserve(1024);

for (std::string_view part : parts) {
    result.append(part);
}

검색과 부분 문자열

std::string text =
    "Programming in C++";

std::size_t position =
    text.find("C++");

if (position != std::string::npos) {
    std::string language =
        text.substr(position);
}

C++23에는 rvalue 문자열에 대한 substr() 동작 등 문자열 기능의 개선도 포함되었다.

std::string_view

std::string_view는 문자 저장 공간을 소유하지 않고 문자열의 연속 구간을 참조한다.

#include <string_view>

void print_message(
    std::string_view message
)
{
    print(
        message.data(),
        message.size()
    );
}

문자열 리터럴, std::string과 문자 배열을 불필요한 복사 없이 받을 수 있다.

print_message("literal");

std::string text = "owned";
print_message(text);

원본 문자 저장 공간이 먼저 파괴되면 뷰가 무효화된다.

std::string_view invalid()
{
    std::string text = "C++";

    return text;
}

string_view는 널 종료를 보장하지 않으며, data()만 C 문자열 API에 전달해서는 안 되는 경우가 있다.

void process(
    std::string_view view
)
{
    legacy_c_api(
        view.data()
    );

    // view가 가리키는 구간 끝에
    // 널 문자가 있다는 보장이 없다.
}

문자 변환

std::to_charsstd::from_chars는 숫자와 문자 시퀀스 사이의 저수준 변환을 제공한다.

#include <charconv>
#include <array>

std::array<char, 32> buffer;

auto result =
    std::to_chars(
        buffer.data(),
        buffer.data() +
            buffer.size(),
        42
    );

if (result.ec == std::errc {}) {
    std::string_view text(
        buffer.data(),
        result.ptr
    );
}
std::string_view text = "42";

int value = 0;

auto result =
    std::from_chars(
        text.data(),
        text.data() +
            text.size(),
        value
    );

if (result.ec != std::errc {}) {
    report_error();
}

이 함수들은 로케일에 의존하지 않고 별도 동적 할당 없이 사용할 수 있다.

정규 표현식

<regex>는 정규 표현식 검색과 치환 기능을 제공한다.

#include <regex>
#include <string>

std::regex pattern(
    R"([A-Za-z_][A-Za-z0-9_]*)"
);

std::string text =
    "value_42";

bool valid =
    std::regex_match(
        text,
        pattern
    );

복잡한 파싱이나 성능이 중요한 토큰 분석에는 전용 파서와 상태 기계를 사용하는 편이 더 적합할 수 있다.

서식 지정

std::format은 형식 문자열과 인자를 바탕으로 새 문자열을 만든다.

#include <format>
#include <string>

std::string text =
    std::format(
        "{} {}",
        "C++",
        23
    );

정렬, 폭, 정밀도와 진법 등을 지정할 수 있다.

std::string text =
    std::format(
        "{:>10} {:08X} {:.2f}",
        "value",
        255,
        3.14159
    );

형식 문자열이 컴파일 시점에 알려지면 인자 타입과 형식 지정의 호환성을 검사할 수 있다.

template<>
struct std::formatter<Vector2> {
    constexpr auto parse(
        std::format_parse_context&
            context
    )
    {
        return context.begin();
    }

    auto format(
        const Vector2& value,
        std::format_context& context
    ) const
    {
        return std::format_to(
            context.out(),
            "({}, {})",
            value.x,
            value.y
        );
    }
};
Vector2 position {
    10.0F,
    20.0F
};

std::string text =
    std::format(
        "position = {}",
        position
    );

출력

C++23의 std::printstd::println은 형식 지정 문자열을 사용해 표준 출력이나 파일에 직접 출력한다.[84]

#include <print>

int main()
{
    std::print(
        "C++{}",
        23
    );

    std::println(
        " standard library"
    );
}

파일 스트림에도 출력할 수 있다.

#include <cstdio>
#include <print>

std::FILE* file =
    std::fopen(
        "log.txt",
        "w"
    );

if (file) {
    std::println(
        file,
        "value = {}",
        42
    );

    std::fclose(file);
}

스트림 입출력

iostream 라이브러리는 스트림 버퍼와 형식 지정 입출력 인터페이스를 제공한다.

#include <iostream>

int value = 0;

std::cout
    << "값을 입력하십시오: ";

std::cin >> value;

std::cout
    << "입력값: "
    << value
    << '\n';

주요 표준 스트림은 다음과 같다.

객체용도
std::cin표준 입력
std::cout표준 출력
std::cerr버퍼링 특성이 다른 표준 오류 출력
std::clog로그용 표준 오류 스트림

스트림 상태

int value = 0;

if (!(std::cin >> value)) {
    std::cin.clear();

    std::cin.ignore(
        std::numeric_limits<
            std::streamsize
        >::max(),
        '\n'
    );
}

스트림은 정상, 파일 끝, 형식 오류와 치명적 오류 상태를 추적한다.

파일 스트림

#include <fstream>
#include <string>

std::ifstream input(
    "document.txt"
);

if (!input) {
    throw FileError(
        "document.txt"
    );
}

std::string line;

while (std::getline(
           input,
           line
       )) {
    process(line);
}
std::ofstream output(
    "result.txt"
);

output
    << "C++"
    << '\n';

스트림 객체의 소멸자가 파일 자원을 반환한다.

이진 입출력

std::ifstream input(
    "data.bin",
    std::ios::binary
);

std::array<std::byte, 1024>
    buffer;

input.read(
    reinterpret_cast<char*>(
        buffer.data()
    ),
    static_cast<std::streamsize>(
        buffer.size()
    )
);

객체의 메모리 표현을 그대로 파일에 기록하면 패딩, 엔디언, 타입 크기와 ABI 차이에 영향을 받을 수 있으므로 외부 파일 형식에서는 명시적인 직렬화 규칙이 필요하다.

문자열 스트림

#include <sstream>

std::ostringstream output;

output
    << "value = "
    << 42;

std::string text =
    output.str();
std::istringstream input(
    "10 20 30"
);

int first = 0;
int second = 0;
int third = 0;

input
    >> first
    >> second
    >> third;

스팬 스트림

C++23의 std::spanstream 계열은 호출자가 제공한 연속 문자 버퍼를 스트림 버퍼로 사용한다.

#include <array>
#include <spanstream>

std::array<char, 128> buffer;

std::ospanstream output(
    buffer
);

output
    << "value = "
    << 42;

동적 문자열 버퍼를 새로 할당하지 않고 기존 저장 공간에 스트림 형식으로 기록할 수 있다.

파일 시스템

<filesystem>은 파일 경로, 디렉터리 순회, 파일 상태와 파일 시스템 작업을 표준화한다.[85]

경로

#include <filesystem>

namespace fs =
    std::filesystem;

fs::path path =
    fs::path("assets") /
    "textures" /
    "surface.png";

경로 연결에 운영체제별 구분자를 직접 작성할 필요가 없다.

fs::path path =
    "/home/user/file.txt";

fs::path filename =
    path.filename();

fs::path extension =
    path.extension();

fs::path parent =
    path.parent_path();

파일 상태

if (fs::exists(path) &&
    fs::is_regular_file(path)) {
    auto size =
        fs::file_size(path);
}

디렉터리 생성

fs::create_directories(
    "build/cache/textures"
);

디렉터리 순회

for (const fs::directory_entry&
         entry :
     fs::directory_iterator(
         "assets"
     )) {
    print(
        entry.path().string()
    );
}

하위 디렉터리까지 순회하려면 재귀 반복자를 사용한다.

for (const auto& entry :
     fs::recursive_directory_iterator(
         "assets"
     )) {
    if (entry.is_regular_file()) {
        process(entry.path());
    }
}

오류 처리

파일 시스템 함수는 예외를 던지는 형식과 std::error_code를 받는 형식을 제공한다.

std::error_code error;

bool exists =
    fs::exists(
        path,
        error
    );

if (error) {
    report(error.message());
}

예상 가능한 파일 부재와 권한 오류를 명시적으로 처리해야 하는 코드에서는 오류 코드 형식을 사용할 수 있다.

시간 라이브러리

std::chrono는 시간 간격, 시점과 시계를 타입 안전하게 표현한다.[86]

시간 간격

#include <chrono>

using namespace
    std::chrono_literals;

std::chrono::milliseconds
    timeout = 500ms;

std::chrono::seconds
    interval = 2s;

단위가 타입의 일부이므로 변환 규칙을 적용할 수 있다.

auto milliseconds =
    std::chrono::duration_cast<
        std::chrono::milliseconds
    >(interval);

시점과 시계

auto start =
    std::chrono::steady_clock::now();

perform_work();

auto end =
    std::chrono::steady_clock::now();

auto elapsed =
    end - start;

steady_clock은 단조 증가하는 시간 측정에 사용된다.

double milliseconds =
    std::chrono::duration<
        double,
        std::milli
    >(elapsed).count();

system_clock은 시스템의 실제 달력 시간과 연결될 수 있다.

auto now =
    std::chrono::system_clock::now();

std::time_t value =
    std::chrono::system_clock::
        to_time_t(now);

달력

C++20 이후 달력 날짜를 타입으로 표현할 수 있다.

using namespace std::chrono;

year_month_day date =
    2026y /
    July /
    16d;
if (date.ok()) {
    process(date);
}

시간대

시간대 데이터베이스가 지원되는 구현에서는 지역 시간과 UTC 사이를 변환할 수 있다.

using namespace std::chrono;

zoned_time seoul_time {
    "Asia/Seoul",
    system_clock::now()
};

시간대 데이터의 실제 제공과 갱신 방식은 구현체와 시스템 환경에 영향을 받을 수 있다.

난수

<random>은 난수 엔진과 확률 분포를 분리한다.

#include <random>

std::random_device seed_source;

std::mt19937 engine(
    seed_source()
);

std::uniform_int_distribution<int>
    distribution(1, 6);

int dice =
    distribution(engine);

난수 엔진은 의사 난수 상태와 생성 규칙을 담당하고, 분포는 엔진의 출력을 원하는 확률 분포로 변환한다.

정수 균등 분포

std::uniform_int_distribution<int>
    distribution(
        minimum,
        maximum
    );

실수 균등 분포

std::uniform_real_distribution<double>
    distribution(
        0.0,
        1.0
    );

정규 분포

std::normal_distribution<double>
    distribution(
        0.0,
        1.0
    );

게임의 결정적 시뮬레이션과 재현 가능한 테스트에서는 고정된 시드를 사용할 수 있다.

std::mt19937 engine(
    0x12345678
);

암호학적 보안이 필요한 난수는 일반적인 의사 난수 엔진만으로 구성하지 않고 운영체제와 암호 라이브러리의 보안 난수 기능을 사용할 수 있다.

스마트 포인터

표준 라이브러리는 동적 객체의 소유권을 표현하는 스마트 포인터를 제공한다.

std::unique_ptr

#include <memory>

auto texture =
    std::make_unique<Texture>(
        "surface.png"
    );

단독 소유권을 가지며 복사할 수 없고 이동할 수 있다.

void register_texture(
    std::unique_ptr<Texture>
        texture
);

register_texture(
    std::move(texture)
);

std::shared_ptr

auto state =
    std::make_shared<State>();

auto first = state;
auto second = state;

마지막 소유자가 사라질 때 객체를 파괴한다.

std::weak_ptr

std::weak_ptr<State>
    observer = state;

if (auto owned =
        observer.lock()) {
    owned->update();
}

공동 객체를 소유하지 않고 관찰해 순환 소유를 피할 수 있다.

할당자와 메모리 자원

컨테이너는 저장 공간 확보 방법을 할당자 타입으로 매개변수화할 수 있다.

std::vector<
    int,
    std::allocator<int>
> values;

대부분의 프로그램은 기본 할당자를 사용하지만 메모리 풀, 공유 메모리와 추적 할당에는 사용자 정의 할당 전략을 사용할 수 있다.

std::pmr

다형적 메모리 자원은 실행 시간에 메모리 전략을 선택할 수 있게 한다.

#include <memory_resource>

std::pmr::vector<int> values;
std::array<std::byte, 4096>
    storage;

std::pmr::monotonic_buffer_resource
    resource(
        storage.data(),
        storage.size()
    );

std::pmr::vector<int>
    values(&resource);

monotonic_buffer_resource는 개별 할당을 즉시 반환하지 않고 자원 전체가 파괴될 때 저장 공간을 한꺼번에 반환한다.

객체 수명 지원

<memory>는 이미 확보된 저장 공간에 객체를 생성하고 파괴하기 위한 도구도 제공한다.

alignas(Widget)
std::byte storage[
    sizeof(Widget)
];

Widget* widget =
    std::construct_at(
        reinterpret_cast<
            Widget*
        >(storage),
        1280,
        720
    );

std::destroy_at(widget);

컨테이너와 사용자 정의 저장소는 할당과 객체 생성을 분리해 관리할 수 있다.

일반 유틸리티 타입

std::pair

std::pair<std::string, int>
    version {
        "C++23",
        2023
    };

print(
    version.first,
    version.second
);

두 값을 하나의 객체로 묶는다.

구조적 바인딩을 사용할 수 있다.

auto [name, year] =
    version;

std::tuple

#include <tuple>

std::tuple<
    std::string,
    int,
    bool
> information {
    "C++",
    23,
    true
};
auto& name =
    std::get<0>(
        information
    );
auto [name, version, supported] =
    information;

std::optional

std::optional<T>는 값이 있거나 없는 상태를 표현한다.

#include <optional>

std::optional<User>
find_user(UserId id)
{
    if (User* user =
            storage.find(id)) {
        return *user;
    }

    return std::nullopt;
}
if (auto user =
        find_user(id)) {
    print(user->name);
}

C++23은 and_then, transform, or_else 같은 모나딕 연산을 제공한다.

auto result =
    find_user(id)
    .transform(
        [](const User& user) {
            return user.name;
        }
    )
    .or_else(
        [] {
            return std::optional<
                std::string
            >("unknown");
        }
    );

std::variant

std::variant는 정해진 여러 타입 가운데 정확히 하나의 값을 저장하는 태그된 합 타입이다.

#include <variant>

using Value =
    std::variant<
        int,
        double,
        std::string
    >;

Value value = 42;

value =
    std::string("C++");

현재 타입을 검사할 수 있다.

if (std::holds_alternative<
        std::string
    >(value)) {
    print(
        std::get<std::string>(
            value
        )
    );
}

std::visit로 타입별 동작을 수행할 수 있다.

std::visit(
    [](const auto& current) {
        print(current);
    },
    value
);

여러 오버로드를 결합할 수 있다.

template<class... Functions>
struct Overloaded :
    Functions... {
    using Functions::operator()...;
};

template<class... Functions>
Overloaded(Functions...)
    -> Overloaded<Functions...>;
std::visit(
    Overloaded {
        [](int value) {
            print_integer(value);
        },
        [](double value) {
            print_real(value);
        },
        [](const std::string& value) {
            print_text(value);
        }
    },
    value
);

std::any

std::any는 복사 가능한 임의 타입 하나를 저장할 수 있다.

#include <any>

std::any value =
    std::string("C++");

if (auto* text =
        std::any_cast<
            std::string
        >(&value)) {
    print(*text);
}

허용 타입 집합을 미리 알고 있다면 std::variant가 타입 관계를 더 명확하게 나타낼 수 있다.

std::expected

C++23의 std::expected<T, E>는 성공값 T 또는 오류값 E를 저장한다.[87]

#include <expected>

enum class ParseError {
    Empty,
    Invalid,
    OutOfRange
};

std::expected<
    int,
    ParseError
>
parse_number(
    std::string_view text
)
{
    if (text.empty()) {
        return std::unexpected(
            ParseError::Empty
        );
    }

    int value = 0;

    auto result =
        std::from_chars(
            text.data(),
            text.data() +
                text.size(),
            value
        );

    if (result.ec ==
        std::errc::
            result_out_of_range) {
        return std::unexpected(
            ParseError::OutOfRange
        );
    }

    if (result.ec !=
        std::errc {}) {
        return std::unexpected(
            ParseError::Invalid
        );
    }

    return value;
}
auto result =
    parse_number("42");

if (!result) {
    handle_error(
        result.error()
    );

    return;
}

int value = *result;

예상 가능한 실패를 반환 타입에 포함할 수 있으며, 예외를 사용하지 않는 오류 전달 구조에 활용된다.

함수 호출 지원

std::function

std::function은 호환되는 호출 시그니처를 가진 함수, 함수 객체와 람다를 하나의 타입으로 저장하는 타입 소거 래퍼다.

#include <functional>

std::function<int(int)>
    operation =
        [](int value) {
            return value * 2;
        };

int result =
    operation(21);

서로 다른 함수 객체를 같은 컨테이너에 저장할 수 있다.

std::vector<
    std::function<void()>
> tasks;

tasks.emplace_back(
    [] {
        render();
    }
);

tasks.emplace_back(
    [value = 42] {
        process(value);
    }
);

호출 대상의 타입과 수명을 지우는 대신 간접 호출과 저장 비용이 발생할 수 있다.

std::invoke

#include <functional>

int result =
    std::invoke(
        function,
        argument
    );

자유 함수, 함수 객체, 멤버 함수 포인터와 데이터 멤버 포인터를 공통 형식으로 호출한다.

struct User {
    int level;

    int score() const
    {
        return level * 10;
    }
};

User user {
    .level = 3
};

int first =
    std::invoke(
        &User::score,
        user
    );

int second =
    std::invoke(
        &User::level,
        user
    );

std::bind

using namespace
    std::placeholders;

auto add_ten =
    std::bind(
        add,
        _1,
        10
    );

현대 C++에서는 많은 경우 람다가 캡처와 인자 관계를 더 직접적으로 표현한다.

auto add_ten =
    [](int value) {
        return add(
            value,
            10
        );
    };

참조 래퍼

#include <functional>

int value = 42;

std::reference_wrapper<int>
    reference = value;

reference.get() = 100;

참조 자체는 컨테이너 원소가 될 수 없지만 reference_wrapper를 사용할 수 있다.

std::vector<
    std::reference_wrapper<int>
> references {
    first,
    second,
    third
};

타입 특성

<type_traits>는 타입의 속성과 관계를 컴파일 시간에 검사하고 새 타입을 계산한다.

#include <type_traits>

static_assert(
    std::is_integral_v<int>
);

static_assert(
    std::is_floating_point_v<
        double
    >
);

static_assert(
    std::is_pointer_v<int*>
);
static_assert(
    std::is_same_v<
        int,
        int
    >
);

static_assert(
    std::is_base_of_v<
        Base,
        Derived
    >
);
using Value =
    std::remove_cvref_t<
        const int&
    >;

static_assert(
    std::is_same_v<
        Value,
        int
    >
);

타입 특성은 템플릿 분기, 직렬화 조건, 최적화와 정적 검증에 사용된다.

동시성

표준 동시성 라이브러리는 스레드, 잠금, 조건 변수, 원자적 객체와 비동기 결과 전달을 제공한다.[88]

std::thread

#include <thread>

void worker(int index)
{
    process(index);
}

std::thread thread(
    worker,
    1
);

thread.join();

join()은 스레드 종료를 기다리고 detach()는 독립 실행으로 분리한다. 조인 가능한 std::thread 객체가 파괴되면 프로그램이 종료될 수 있으므로 수명 관리가 필요하다.

std::jthread

std::jthread는 소멸 시 자동으로 조인하며 중지 토큰을 지원한다.

#include <thread>

std::jthread worker(
    [](std::stop_token stop) {
        while (!stop.stop_requested()) {
            process_one_item();
        }
    }
);
worker.request_stop();

뮤텍스

#include <mutex>

std::mutex mutex;
State state;

void update()
{
    std::lock_guard lock(mutex);

    modify(state);
}

std::lock_guard는 범위 동안 잠금을 보유한다.

여러 뮤텍스를 교착 없이 잠글 때 std::scoped_lock을 사용할 수 있다.

std::scoped_lock lock(
    first_mutex,
    second_mutex
);

공유 뮤텍스

#include <shared_mutex>

std::shared_mutex mutex;
Configuration configuration;

Configuration read()
{
    std::shared_lock lock(mutex);

    return configuration;
}

void write(
    Configuration next
)
{
    std::unique_lock lock(mutex);

    configuration =
        std::move(next);
}

여러 읽기 작업은 동시에 수행하고 쓰기는 배타적으로 수행할 수 있다.

조건 변수

#include <condition_variable>
#include <mutex>
#include <queue>

std::condition_variable condition;
std::mutex mutex;
std::queue<Task> tasks;

Task wait_for_task()
{
    std::unique_lock lock(mutex);

    condition.wait(
        lock,
        [] {
            return !tasks.empty();
        }
    );

    Task task =
        std::move(
            tasks.front()
        );

    tasks.pop();

    return task;
}
{
    std::lock_guard lock(mutex);

    tasks.push(
        std::move(task)
    );
}

condition.notify_one();

원자적 연산

#include <atomic>

std::atomic<int> counter = 0;

void increment()
{
    counter.fetch_add(
        1,
        std::memory_order_relaxed
    );
}

원자적 객체는 동기화되지 않은 일반 객체 접근에서 발생하는 데이터 경쟁을 피할 수 있다. 메모리 순서는 다른 메모리 접근과의 관계를 제어한다.

std::atomic<bool> ready = false;
Data data;

void producer()
{
    data = create_data();

    ready.store(
        true,
        std::memory_order_release
    );
}

void consumer()
{
    while (!ready.load(
               std::memory_order_acquire
           )) {
    }

    use(data);
}

래치와 배리어

std::latch는 정해진 횟수만큼 작업 완료를 기다리는 일회성 동기화 도구다.

#include <latch>

std::latch completed(
    worker_count
);

for (int index = 0;
     index < worker_count;
     ++index) {
    workers.emplace_back(
        [&, index] {
            process(index);

            completed.count_down();
        }
    );
}

completed.wait();

std::barrier는 여러 단계에서 반복적으로 스레드를 동기화할 수 있다.

#include <barrier>

std::barrier phase_barrier(
    worker_count
);

세마포어

#include <semaphore>

std::counting_semaphore<8>
    available_slots(4);

void use_resource()
{
    available_slots.acquire();

    auto release =
        std::scope_exit(
            [] {
                available_slots.release();
            }
        );

    perform_operation();
}

동시에 자원을 사용하는 실행 수를 제한한다.

퓨처와 비동기 작업

std::future는 나중에 준비될 결과를 나타낸다.

#include <future>

std::future<int> result =
    std::async(
        std::launch::async,
        [] {
            return calculate();
        }
    );

int value =
    result.get();

std::promise

std::promise<int> promise;

std::future<int> future =
    promise.get_future();

std::thread worker(
    [promise =
         std::move(promise)]() mutable {
        try {
            promise.set_value(
                calculate()
            );
        }
        catch (...) {
            promise.set_exception(
                std::current_exception()
            );
        }
    }
);

한 실행 주체가 결과 또는 예외를 설정하고 다른 주체가 퓨처로 받는다.

std::packaged_task

std::packaged_task<int()>
    task(
        [] {
            return calculate();
        }
    );

std::future<int> future =
    task.get_future();

std::thread worker(
    std::move(task)
);

호출 가능한 객체의 반환값과 예외를 퓨처에 연결한다.

코루틴 지원

<coroutine>은 C++20 코루틴의 저수준 라이브러리 지원을 제공한다.

#include <coroutine>

std::coroutine_handle<>
    handle;

std::coroutine_handle, std::coroutine_traits, std::suspend_alwaysstd::suspend_never가 코루틴 프레임과 중단 동작을 구성한다.

C++23의 std::generator는 값을 순차적으로 생성하는 동기식 코루틴 범위다.[89]

#include <generator>

std::generator<int>
count_to(int end)
{
    for (int value = 1;
         value <= end;
         ++value) {
        co_yield value;
    }
}
for (int value :
     count_to(5)) {
    print(value);
}

값 전체를 컨테이너에 먼저 저장하지 않고 요청에 따라 하나씩 생성한다.

수치 계산

수학 함수

<cmath>는 제곱근, 삼각 함수, 지수와 로그 등의 수학 함수를 제공한다.

#include <cmath>

double length =
    std::sqrt(
        x * x +
        y * y
    );

double sine =
    std::sin(angle);

double logarithm =
    std::log(value);

복소수

#include <complex>

std::complex<double> first {
    1.0,
    2.0
};

std::complex<double> second {
    3.0,
    4.0
};

auto result =
    first * second;

실수부, 허수부, 크기와 위상을 다룰 수 있다.

double magnitude =
    std::abs(result);

double phase =
    std::arg(result);

std::valarray

#include <valarray>

std::valarray<double> values {
    1.0,
    2.0,
    3.0
};

std::valarray<double> result =
    values * 2.0 + 1.0;

수치 배열의 원소별 연산을 제공한다.

수치 알고리즘

#include <numeric>

int total =
    std::accumulate(
        values.begin(),
        values.end(),
        0
    );

std::reduce는 실행 순서와 병렬 실행 가능성을 더 유연하게 허용한다.

double total =
    std::reduce(
        values.begin(),
        values.end(),
        0.0
    );

접두사 합을 계산할 수 있다.

std::vector<int> result(
    values.size()
);

std::inclusive_scan(
    values.begin(),
    values.end(),
    result.begin()
);

내적을 계산할 수 있다.

double result =
    std::inner_product(
        first.begin(),
        first.end(),
        second.begin(),
        0.0
    );

std::mdspan

C++23의 std::mdspan은 연속 또는 사용자 정의 배치의 다차원 데이터에 대한 비소유 뷰를 제공한다.[90]

#include <mdspan>
#include <vector>

std::vector<double> storage(
    rows * columns
);

std::mdspan matrix(
    storage.data(),
    rows,
    columns
);
for (std::size_t row = 0;
     row < rows;
     ++row) {
    for (std::size_t column = 0;
         column < columns;
         ++column) {
        matrix[row, column] =
            calculate(
                row,
                column
            );
    }
}

mdspan은 저장 공간을 소유하지 않고 데이터 포인터, 차원 크기, 배치 방식과 접근자를 결합한다.

std::array<float, 16>
    storage {};

std::mdspan<
    float,
    std::extents<
        std::size_t,
        4,
        4
    >
> matrix(
    storage.data()
);

일부 또는 모든 차원을 컴파일 시간 상수로 지정할 수 있다.

오류 처리 지원

표준 예외

#include <stdexcept>

throw std::invalid_argument(
    "잘못된 인자"
);

throw std::out_of_range(
    "범위를 벗어났다."
);

throw std::runtime_error(
    "실행 오류"
);

주요 예외 계층은 std::exception에서 파생된다.

try {
    perform_operation();
}
catch (const std::exception& error) {
    print(error.what());
}

오류 코드

std::error_code는 오류 범주와 정수 오류값을 함께 저장한다.

std::error_code error;

std::filesystem::remove(
    path,
    error
);

if (error) {
    print(
        error.value(),
        error.message()
    );
}

외부 시스템 API와 예외를 사용하지 않는 오류 전달에 활용된다.

단정

#include <cassert>

assert(
    index < values.size()
);

assert는 일반적으로 디버그 빌드에서 내부 전제 조건을 검사한다. 사용자 입력과 복구 가능한 오류를 처리하는 수단으로 사용하기보다는 프로그램 내부 불변 조건 검증에 사용한다.

비트와 바이트 도구

<bit>는 바이트 순서, 비트 회전, 비트 개수와 표현 변환 기능을 제공한다.

#include <bit>

unsigned int value =
    0b0011'0000;

int ones =
    std::popcount(value);

unsigned int rotated =
    std::rotl(value, 4);
if constexpr (
    std::endian::native ==
        std::endian::little
) {
    process_little_endian();
}

std::bit_cast는 크기가 같은 타입 사이에서 객체 표현을 복사해 새 값을 만든다.

float value = 1.0F;

std::uint32_t bits =
    std::bit_cast<
        std::uint32_t
    >(value);

C++23의 std::byteswap은 정수의 바이트 순서를 뒤집는다.

std::uint32_t value =
    0x12345678;

std::uint32_t reversed =
    std::byteswap(value);

소스 위치와 스택 추적

std::source_location은 호출 위치의 파일명, 줄, 열과 함수명을 얻는다.

#include <source_location>

void log(
    std::string_view message,
    std::source_location location =
        std::source_location::
            current()
)
{
    std::println(
        "{}:{} {}: {}",
        location.file_name(),
        location.line(),
        location.function_name(),
        message
    );
}

C++23의 std::stacktrace는 실행 중 스택 추적을 표현한다.

#include <stacktrace>

void report_error()
{
    auto trace =
        std::stacktrace::current();

    std::println(
        "{}",
        trace
    );
}

실제 심볼 정보 품질과 지원 여부는 구현체, 플랫폼과 빌드 설정에 영향을 받을 수 있다.

범위 보호

C++23의 범위 보호 타입은 범위 종료 조건에 따라 정리 함수를 실행한다.

#include <scope>

begin_operation();

auto cleanup =
    std::scope_exit(
        [] {
            end_operation();
        }
    );

perform_operation();

std::scope_fail은 예외로 범위를 빠져나갈 때, std::scope_success는 정상적으로 빠져나갈 때 동작을 실행한다.

Transaction transaction;

auto rollback =
    std::scope_fail(
        [&] {
            transaction.rollback();
        }
    );

perform_changes();

transaction.commit();

전용 RAII 클래스가 없는 일회성 정리 흐름에 사용할 수 있다.

라이브러리 구성 요소의 결합

다음 예제는 파일 시스템, 문자열, 범위, 알고리즘, std::expected와 형식 지정 출력을 결합해 특정 확장자의 파일을 검색하고 크기순으로 정렬한다.

#include <algorithm>
#include <expected>
#include <filesystem>
#include <print>
#include <ranges>
#include <string>
#include <system_error>
#include <vector>

namespace fs =
    std::filesystem;

struct FileInformation {
    fs::path path;
    std::uintmax_t size;
};

enum class ScanError {
    DirectoryNotFound,
    PermissionDenied,
    FilesystemFailure
};

std::expected<
    std::vector<FileInformation>,
    ScanError
>
scan_files(
    const fs::path& directory,
    std::string_view extension
)
{
    std::error_code error;

    if (!fs::exists(
            directory,
            error
        )) {
        return std::unexpected(
            ScanError::
                DirectoryNotFound
        );
    }

    if (error) {
        return std::unexpected(
            ScanError::
                FilesystemFailure
        );
    }

    std::vector<FileInformation>
        files;

    fs::directory_iterator iterator(
        directory,
        error
    );

    if (error) {
        if (error ==
            std::errc::
                permission_denied) {
            return std::unexpected(
                ScanError::
                    PermissionDenied
            );
        }

        return std::unexpected(
            ScanError::
                FilesystemFailure
        );
    }

    for (const fs::directory_entry&
             entry :
         iterator) {
        if (!entry.is_regular_file(
                error
            )) {
            error.clear();
            continue;
        }

        if (entry.path().extension()
            != extension) {
            continue;
        }

        std::uintmax_t size =
            entry.file_size(error);

        if (error) {
            error.clear();
            continue;
        }

        files.push_back(
            FileInformation {
                .path = entry.path(),
                .size = size
            }
        );
    }

    std::ranges::sort(
        files,
        std::ranges::greater {},
        &FileInformation::size
    );

    return files;
}

int main()
{
    auto result =
        scan_files(
            "assets",
            ".png"
        );

    if (!result) {
        std::println(
            "파일 검색에 실패했다."
        );

        return 1;
    }

    for (const auto& file :
         *result) {
        std::println(
            "{:>10} {}",
            file.size,
            file.path.string()
        );
    }
}

이 프로그램에서 표준 라이브러리 구성 요소는 다음 역할을 맡는다.

  • std::filesystem::pathdirectory_iterator가 경로와 디렉터리를 처리한다.
  • std::error_code가 파일 시스템 오류를 예외 없이 전달한다.
  • std::expected가 전체 작업의 성공 결과와 오류를 구분한다.
  • std::vector가 가변 개수의 결과를 소유한다.
  • std::string_view가 확장자 문자열을 비소유로 받는다.
  • std::ranges::sort가 프로젝션을 사용해 파일 크기로 정렬한다.
  • std::println이 형식 지정된 결과를 출력한다.
  • RAII 객체가 디렉터리 반복자와 컨테이너 저장 공간을 자동으로 정리한다.

표준 라이브러리와 성능

표준 라이브러리의 추상화는 구체적인 비용 모델을 가진다.

  • std::array는 일반적으로 동적 할당을 사용하지 않는다.
  • std::vector는 연속 저장 공간을 소유하며 용량 증가 시 재할당할 수 있다.
  • 노드 기반 컨테이너는 원소별 할당과 포인터 간접 접근을 사용할 수 있다.
  • std::function은 타입 소거와 간접 호출 비용이 발생할 수 있다.
  • 범위 뷰는 중간 컨테이너 생성을 피할 수 있지만 복잡한 반복자 상태를 가질 수 있다.
  • shared_ptr는 참조 계수와 제어 블록을 관리한다.
  • pmr는 메모리 전략을 런타임에 교체하는 간접 호출을 사용할 수 있다.
  • 정규 표현식의 컴파일과 실행 비용은 패턴과 구현체에 크게 영향을 받을 수 있다.
  • 병렬 알고리즘은 데이터 크기, 작업 비용과 런타임 환경에 따라 순차 실행보다 느릴 수도 있다.

표준 라이브러리는 특정 구현 알고리즘을 항상 고정하기보다 복잡도, 의미와 인터페이스 요구 사항을 규정하는 경우가 많다. 실제 성능은 원소 타입, 할당 패턴, 캐시 지역성, 구현체와 컴파일 옵션에 따라 달라진다.

표준 라이브러리와 이식성

표준 라이브러리는 운영체제별 API 차이를 줄이지만 모든 플랫폼 차이를 완전히 제거하지는 않는다.

  • std::filesystem::path의 기본 문자 표현은 플랫폼에 따라 다를 수 있다.
  • 파일 권한과 심볼릭 링크 동작은 운영체제 파일 시스템에 영향을 받는다.
  • 시간대 데이터베이스 제공 방식은 구현 환경에 따라 다르다.
  • 스레드 스케줄링과 성능은 운영체제에 영향을 받는다.
  • 부동소수점 함수의 세부 오차는 하드웨어와 구현체에 따라 달라질 수 있다.
  • 표준 라이브러리 ABI는 컴파일러와 구현체 버전에 따라 다를 수 있다.
  • 최신 표준 기능의 구현 완료 시점은 컴파일러별로 다르다.

공개 바이너리 인터페이스에서 표준 컨테이너와 문자열 타입을 직접 노출하면 동일 컴파일러와 호환 가능한 표준 라이브러리 ABI를 요구할 수 있다.

class PublicApi {
public:
    virtual std::vector<
        std::string
    > names() const = 0;
};

플러그인이나 다른 컴파일러 사이의 안정적인 경계가 필요하면 C ABI, 불투명 핸들, PImpl 또는 명시적인 버퍼 인터페이스를 사용할 수 있다.

extern "C" {

struct LibraryHandle;

LibraryHandle*
library_create();

void library_destroy(
    LibraryHandle* library
);

std::size_t library_get_name(
    LibraryHandle* library,
    std::size_t index,
    char* output,
    std::size_t capacity
);

}

내부 구현에서는 표준 라이브러리를 자유롭게 사용할 수 있다.

표준 라이브러리 사용 원칙

C++ 표준 라이브러리를 사용할 때의 일반적인 방향은 다음과 같이 정리할 수 있다.

  1. 직접 배열과 수동 크기 관리 대신 적절한 컨테이너를 사용한다.
  2. 컨테이너에 맞춰 알고리즘을 다시 작성하기보다 표준 알고리즘과 범위를 검토한다.
  3. 소유 문자열에는 std::string, 비소유 접근에는 std::string_view를 사용한다.
  4. 연속 데이터 비소유 접근에는 std::span을 사용할 수 있다.
  5. 단독 동적 소유권은 std::unique_ptr로 표현한다.
  6. 실제 공동 소유가 있을 때만 std::shared_ptr를 사용한다.
  7. 시간 단위는 단순 정수보다 std::chrono::duration으로 표현한다.
  8. 수동 경로 문자열 처리보다 std::filesystem::path를 사용한다.
  9. 예상 가능한 성공 또는 실패는 std::optional, std::expected나 오류 코드로 표현할 수 있다.
  10. 타입 집합이 고정된 값에는 std::variant를 사용할 수 있다.
  11. 메모리 할당 패턴이 특별할 때 할당자와 std::pmr를 검토한다.
  12. 동시성 코드에서는 RAII 잠금과 원자적 메모리 모델을 함께 고려한다.
  13. 컨테이너의 반복자 무효화와 복잡도 보장을 확인한다.
  14. 최신 표준 기능을 사용할 때 대상 컴파일러와 표준 라이브러리의 지원 상태를 확인한다.
  15. 표준 라이브러리 타입을 공개 ABI에 노출할 때 구현체 호환성을 고려한다.

C++ 표준 라이브러리는 언어의 부가적인 편의 기능이 아니라 C++ 프로그래밍 모델의 핵심 부분이다. 템플릿과 개념이 자료구조와 알고리즘을 연결하고, RAII와 할당자가 자원 수명을 관리하며, 범위와 함수 객체가 처리 동작을 조합한다.

표준 컨테이너와 알고리즘을 사용하면 자료구조의 객체 수명, 예외 처리와 반복 규칙을 매번 다시 구현하지 않아도 된다. 동시에 반복자 범주, 메모리 배치, 실행 정책과 사용자 정의 할당자 같은 확장 지점을 통해 시스템 프로그래밍에 필요한 비용과 동작을 제어할 수 있다.

동시성과 비동기 실행

C++의 동시성 기능은 하나의 프로그램 안에서 여러 실행 흐름이 겹쳐 진행되도록 하고, 이들이 메모리와 자원을 안전하게 공유하거나 메시지와 결과를 주고받도록 구성한다. 여기에는 운영체제 스레드를 직접 다루는 다중 스레드 프로그래밍, 원자적 연산과 뮤텍스를 이용한 공유 메모리 동기화, 태스크와 퓨처를 이용한 결과 중심 비동기 실행, 코루틴을 이용한 중단과 재개, 그리고 병렬 알고리즘과 실행 정책을 통한 데이터 병렬 처리가 포함된다.

C++11은 언어 차원의 메모리 모델과 표준 동시성 라이브러리를 도입했다. 이로써 스레드, 뮤텍스와 원자적 연산이 특정 운영체제 API의 관례가 아니라 C++ 추상 기계 안에서 정의되는 프로그램 동작이 되었다. 표준 동시성 라이브러리는 스레드의 생성과 관리, 상호 배제, 원자적 연산, 조건과 값의 스레드 간 전달을 제공한다.[91][92]

동시성과 병렬성, 비동기성은 서로 관련되지만 같은 개념은 아니다.

  • 동시성은 여러 작업의 진행 시간이 서로 겹칠 수 있도록 프로그램을 구성하는 성질이다.
  • 병렬성은 여러 작업이 실제로 같은 시간에 서로 다른 처리 장치나 코어에서 실행되는 성질이다.
  • 비동기 실행은 작업을 시작한 호출자가 그 완료를 즉시 기다리지 않고 다른 작업을 계속할 수 있는 실행 방식이다.
  • 벡터화는 하나의 명령이나 실행 단위가 여러 데이터 요소를 동시에 처리하도록 구성하는 데이터 병렬 방식이다.

하나의 CPU 코어에서도 작업을 번갈아 실행해 동시성을 구현할 수 있지만 실제 병렬 실행은 일어나지 않을 수 있다. 반대로 컴파일러가 단일 알고리즘을 SIMD 명령으로 병렬화하더라도 프로그램이 여러 독립 작업을 비동기적으로 관리하는 것은 아닐 수 있다.

C++ 동시성과 비동기 실행 구조
  • 동시성과 비동기 실행
    • 실행 스레드
      • std::thread
      • std::jthread
      • 협력적 중지
    • 공유 메모리 동기화
      • 뮤텍스
      • 조건 변수
      • 세마포어
      • 래치와 배리어
      • 원자적 연산
    • 태스크와 결과
      • std::async
      • std::future
      • std::promise
      • std::packaged_task
    • 병렬 알고리즘
      • 실행 정책
      • 병렬 실행
      • 비순차·벡터화 실행
    • 코루틴
      • 중단과 재개
      • co_await
      • co_yield
      • co_return
    • 차기 비동기 실행 모델
      • 스케줄러
      • sender
      • receiver
      • std::execution

C++ 메모리 모델

여러 실행 스레드가 같은 메모리에 접근할 때에는 단순히 소스 코드의 문장 순서만으로 실제 관찰 순서를 판단할 수 없다. 컴파일러는 as-if 규칙에 따라 연산을 재배치할 수 있고, 프로세서는 캐시와 저장 버퍼, 비순차 실행을 사용할 수 있다. C++ 메모리 모델은 이러한 환경에서도 스레드 사이에서 어떤 읽기와 쓰기가 관찰될 수 있는지, 어떤 연산이 서로 동기화되는지, 데이터 경쟁이 발생했을 때 프로그램이 어떤 의미를 갖는지를 정의한다.

C++ 표준은 원자적 연산과 뮤텍스 연산 가운데 일부를 동기화 연산으로 식별한다. 이러한 연산이 형성하는 순서 관계를 통해 한 스레드의 쓰기가 다른 스레드에서 안전하게 관찰될 수 있다.[93][94]

실행 스레드

실행 스레드는 프로그램 안에서 표현식과 문장을 순서대로 평가하는 하나의 실행 흐름이다. 프로그램은 처음에 주 실행 스레드에서 시작하며, 표준 라이브러리 기능을 통해 추가 스레드를 만들 수 있다.

각 스레드 안에서는 해당 스레드의 평가가 일정한 sequenced-before 관계를 형성한다. 그러나 서로 다른 스레드 사이에는 동기화가 없다면 일반적인 전체 실행 순서가 자동으로 만들어지지 않는다.

#include <thread>

void first_task()
{
    perform_first_operation();
}

void second_task()
{
    perform_second_operation();
}

int main()
{
    std::thread first(first_task);
    std::thread second(second_task);

    first.join();
    second.join();
}

first_task()second_task() 가운데 어느 쪽이 먼저 실행되거나 끝나는지는 고정되지 않는다. 두 함수가 서로 독립된 데이터만 사용한다면 실행 순서를 정할 필요가 없다.

메모리 위치

메모리 위치는 데이터 경쟁을 판단하는 단위다. 일반적으로 하나의 스칼라 객체 또는 연속된 비트 필드의 집합 등이 하나의 메모리 위치를 이룬다.

struct Counters {
    int first;
    int second;
};

Counters counters;

서로 다른 스레드가 firstsecond라는 별도 객체를 각각 수정하는 것은 같은 메모리 위치의 충돌 접근이 아닐 수 있다.

void update_first()
{
    ++counters.first;
}

void update_second()
{
    ++counters.second;
}

그러나 구조체와 하드웨어 캐시 관점에서는 두 멤버가 같은 캐시 라인에 배치되어 false sharing이 발생할 수 있다. 이는 언어 수준 데이터 경쟁과 별개의 성능 문제다.

충돌하는 평가

두 평가가 같은 메모리 위치에 접근하고, 그중 하나 이상이 값을 수정하거나 겹치는 객체의 수명을 시작 또는 끝내면 서로 충돌할 수 있다.

int counter = 0;

void increment()
{
    ++counter;
}

++counter는 단일한 추상 연산처럼 보이지만 일반적으로 다음과 같은 읽기·계산·쓰기를 포함한다.

`counter` 읽기
↓
값에 1 더하기
↓
새 값을 `counter`에 쓰기

여러 스레드가 동기화 없이 동시에 실행하면 각 단계가 서로 겹칠 수 있다.

데이터 경쟁

서로 다른 스레드에서 충돌하는 평가가 발생하고, 두 연산이 모두 원자적이지 않으며, 둘 사이에 적절한 happens-before 관계가 없으면 데이터 경쟁이 발생한다. 데이터 경쟁이 있는 프로그램의 동작은 정의되지 않는다.

#include <thread>

int counter = 0;

void increment()
{
    for (int index = 0;
         index < 100'000;
         ++index) {
        ++counter;
    }
}

int main()
{
    std::thread first(increment);
    std::thread second(increment);

    first.join();
    second.join();

    return counter;
}

이 코드는 결과가 단순히 200000보다 작을 수 있다는 정도로만 정의되지 않는 것이 아니다. 데이터 경쟁 자체가 미정의 동작이므로 컴파일러는 정의된 프로그램에서는 이런 충돌이 없다고 가정할 수 있다.

volatile은 데이터 경쟁을 해결하지 않는다.

volatile int counter = 0;

void increment()
{
    ++counter;
}

volatile은 일반적인 스레드 간 원자성과 동기화 순서를 제공하지 않는다. 공유 데이터에는 뮤텍스나 std::atomic을 사용해야 한다.

happens-before

happens-before는 한 평가의 효과가 다른 평가보다 앞선다는 것을 C++ 메모리 모델 안에서 보장하는 관계다. 한 스레드 내부의 sequenced-before 관계와 스레드 간 synchronizes-with 관계 등이 결합되어 형성된다.

뮤텍스 해제와 이후 같은 뮤텍스의 성공적인 잠금은 대표적인 동기화 관계를 만든다.

#include <mutex>
#include <thread>

std::mutex mutex;
int shared_value = 0;

void producer()
{
    std::lock_guard lock(mutex);

    shared_value = 42;
}

void consumer()
{
    std::lock_guard lock(mutex);

    use(shared_value);
}

consumer()producer()가 잠금을 해제한 뒤 같은 뮤텍스를 잠그면, 생산자가 보호 구역 안에서 수행한 쓰기를 소비자가 관찰할 수 있다.

가시성과 순서

동기화는 단순히 두 스레드가 같은 순간에 메모리를 읽지 못하게 하는 것만이 아니다. 적절한 동기화 연산은 보호된 데이터에 대한 이전 쓰기가 이후 잠금 획득 스레드에서 관찰되도록 메모리 가시성 관계도 형성한다.

struct Data {
    int first;
    int second;
};

std::mutex mutex;
Data data;
bool ready = false;

void produce()
{
    std::lock_guard lock(mutex);

    data.first = 10;
    data.second = 20;
    ready = true;
}

void consume()
{
    std::lock_guard lock(mutex);

    if (ready) {
        use(data);
    }
}

모든 관련 상태가 같은 뮤텍스로 보호되므로 소비자는 ready가 참일 때 완성된 data를 관찰한다.

std::thread

std::thread는 하나의 실행 스레드를 나타내는 이동 전용 객체다. 생성자에 호출 가능한 객체와 인자를 전달하면 새 실행 스레드가 시작된다.

#include <thread>

void worker(int index)
{
    process(index);
}

int main()
{
    std::thread thread(
        worker,
        1
    );

    thread.join();
}

join()은 대상 스레드가 끝날 때까지 현재 스레드를 대기시킨다. 대상 스레드의 완료는 성공한 join() 반환과 동기화된다.

람다 실행

std::thread thread(
    [] {
        perform_background_work();
    }
);

thread.join();

외부 값을 캡처할 수 있다.

int task_id = 42;

std::thread thread(
    [task_id] {
        process(task_id);
    }
);

thread.join();

값 캡처는 새 스레드가 독립적인 복사본을 사용하게 한다.

참조 전달

std::thread는 일반적으로 전달받은 인자를 내부 저장소로 복사하거나 이동한다. 참조로 전달하려면 std::ref 또는 람다 참조 캡처를 사용할 수 있다.

#include <functional>
#include <thread>

void increment(int& value)
{
    ++value;
}

int value = 0;

std::thread thread(
    increment,
    std::ref(value)
);

thread.join();

새 스레드가 사용하는 동안 원본 객체가 살아 있어야 한다.

std::thread create_invalid_thread()
{
    int local_value = 42;

    return std::thread(
        [&local_value] {
            use(local_value);
        }
    );
}

함수가 반환되면 local_value의 수명이 끝나므로 스레드가 해당 참조를 사용하면 유효하지 않은 객체에 접근한다.

이동 전용 인자

auto resource =
    std::make_unique<Resource>();

std::thread thread(
    [](std::unique_ptr<Resource> owned) {
        owned->run();
    },
    std::move(resource)
);

thread.join();

소유권을 새 실행 스레드로 이전할 수 있다.

joinable()

std::thread 객체가 실행 스레드를 나타내는지는 joinable()로 확인한다.

std::thread thread(worker);

if (thread.joinable()) {
    thread.join();
}

기본 생성됐거나 이동된 원본, join() 또는 detach()가 끝난 객체는 조인 가능한 스레드를 나타내지 않는다.

소멸과 std::terminate

조인 가능한 std::thread가 파괴되면 자동으로 조인되지 않고 std::terminate가 호출된다.

void run()
{
    std::thread thread(worker);

    // join이나 detach 없이 함수가 끝나면
    // 조인 가능한 thread의 소멸자가 terminate를 호출한다.
}

예외나 조기 반환을 포함한 모든 경로에서 조인을 보장해야 한다.

void run()
{
    std::thread thread(worker);

    try {
        perform_main_work();
    }
    catch (...) {
        if (thread.joinable()) {
            thread.join();
        }

        throw;
    }

    thread.join();
}

C++20 이후에는 대부분의 범위 기반 스레드에 std::jthread가 더 직접적인 RAII 구조를 제공한다.

detach()

detach()std::thread 객체와 실행 스레드의 연결을 끊는다.

std::thread thread(
    [] {
        perform_background_work();
    }
);

thread.detach();

분리된 스레드의 완료를 원래 thread 객체로 기다리거나 결과를 받을 수 없다. 프로세스 종료, 공유 객체의 수명과 오류 전달을 별도로 관리해야 하므로 범위가 명확한 작업에서는 조인 또는 상위 태스크 시스템을 사용하는 편이 구조를 예측하기 쉽다.

std::jthread

std::jthread는 C++20에 추가된 조인 스레드다. 일반적인 동작은 std::thread와 비슷하지만 객체가 파괴될 때 조인 가능한 경우 중지를 요청하고 자동으로 조인한다. 또한 std::stop_token 기반의 협력적 중지 기능을 통합한다.[95]

#include <thread>

void run()
{
    std::jthread worker(
        [] {
            perform_work();
        }
    );

    perform_main_work();

    // 범위를 벗어나면 worker가 자동으로 조인된다.
}

복사할 수 없고 이동할 수 있다는 점도 std::thread와 같다.

협력적 중지

std::jthread가 실행할 함수가 첫 번째 인자로 std::stop_token을 받을 수 있다면 내부 중지 토큰이 전달된다.

#include <chrono>
#include <thread>

using namespace std::chrono_literals;

std::jthread worker(
    [](std::stop_token stop) {
        while (!stop.stop_requested()) {
            process_one_item();

            std::this_thread::sleep_for(
                10ms
            );
        }

        cleanup_worker();
    }
);

외부에서 중지를 요청할 수 있다.

worker.request_stop();

request_stop()은 대상 스레드를 강제로 종료하지 않는다. 공유 중지 상태를 변경할 뿐이며 실행 함수가 토큰을 확인하고 스스로 반환해야 한다.

협력적 중지 흐름
  1. 작업 스레드 시작
  2. 반복 중 stop_requested() 확인
  3. 외부에서 request_stop() 호출
  4. 공유 중지 상태 변경
  5. 작업이 요청을 관찰
  6. 현재 자원과 불변 조건 정리
  7. 실행 함수 반환

강제 스레드 종료는 잠금을 보유하거나 객체를 수정하는 중간 지점에서 실행을 끊어 프로그램 상태를 손상시킬 수 있다. 협력적 중지는 코드가 안전한 중단 지점을 직접 선택하게 한다.

중지 토큰

협력적 중지 구조는 std::stop_source, std::stop_tokenstd::stop_callback으로 구성된다.

std::stop_source source;

std::stop_token token =
    source.get_token();

중지 요청은 소스를 통해 수행한다.

source.request_stop();

bool requested =
    token.stop_requested();

중지 콜백

std::stop_callback callback(
    token,
    [] {
        wake_blocked_operation();
    }
);

중지가 요청되면 등록된 콜백이 실행된다. 콜백 함수는 중지 요청을 수행한 스레드 또는 등록 시점의 상태에 따라 즉시 실행될 수 있으므로 짧고 예외를 밖으로 전달하지 않는 동작으로 설계할 수 있다.

조건 변수와 중지

std::condition_variable_any는 중지 토큰과 결합한 대기 형식을 제공한다.

std::mutex mutex;
std::condition_variable_any condition;
std::queue<Task> tasks;

void worker(std::stop_token stop)
{
    while (true) {
        std::unique_lock lock(mutex);

        bool ready =
            condition.wait(
                lock,
                stop,
                [] {
                    return !tasks.empty();
                }
            );

        if (!ready) {
            return;
        }

        Task task =
            std::move(
                tasks.front()
            );

        tasks.pop();

        lock.unlock();

        task();
    }
}

중지 요청과 작업 도착 가운데 하나가 발생할 때까지 기다릴 수 있다.

뮤텍스와 상호 배제

뮤텍스는 한 시점에 제한된 수의 스레드만 보호 구역에 들어가게 한다. 일반적인 std::mutex는 한 스레드가 잠금을 획득하면 해제될 때까지 다른 스레드의 획득을 막는다.

#include <mutex>

std::mutex mutex;
int counter = 0;

void increment()
{
    mutex.lock();

    ++counter;

    mutex.unlock();
}

직접 lock()unlock()을 호출하면 중간 반환과 예외에서 잠금 해제를 놓칠 수 있다.

void update()
{
    mutex.lock();

    if (!is_valid()) {
        return;
    }

    modify_shared_state();

    mutex.unlock();
}

조기 반환 시 뮤텍스가 계속 잠긴다.

RAII 잠금 객체를 사용할 수 있다.

void update()
{
    std::lock_guard lock(mutex);

    if (!is_valid()) {
        return;
    }

    modify_shared_state();
}

std::lock_guard가 파괴되면 잠금이 자동으로 해제된다.

보호 대상과 뮤텍스의 결합

뮤텍스와 보호하는 상태를 같은 클래스에 배치하면 동기화 계약을 한 타입 안에 둘 수 있다.

class Counter {
    mutable std::mutex mutex_;
    int value_ = 0;

public:
    void increment()
    {
        std::lock_guard lock(mutex_);

        ++value_;
    }

    int value() const
    {
        std::lock_guard lock(mutex_);

        return value_;
    }
};

외부 코드가 value_에 동기화 없이 직접 접근할 수 없다.

std::unique_lock

std::unique_lock은 이동 가능하며 지연 잠금, 잠금 시도, 명시적 해제와 재잠금을 지원한다.

std::unique_lock lock(
    mutex,
    std::defer_lock
);

prepare_without_lock();

lock.lock();

modify_shared_state();

lock.unlock();

perform_expensive_work();

조건 변수는 잠금의 임시 해제와 재획득이 필요하므로 일반적으로 std::unique_lock을 사용한다.

std::scoped_lock

여러 뮤텍스를 동시에 잠글 때에는 잠금 순서가 다른 코드 사이에서 교착 상태가 생길 수 있다.

void first_operation()
{
    std::lock_guard first_lock(
        first_mutex
    );

    std::lock_guard second_lock(
        second_mutex
    );
}

void second_operation()
{
    std::lock_guard second_lock(
        second_mutex
    );

    std::lock_guard first_lock(
        first_mutex
    );
}

각 스레드가 하나의 뮤텍스를 잡고 상대 뮤텍스를 기다리면 교착 상태가 된다.

std::scoped_lock은 여러 뮤텍스를 교착 회피 알고리즘으로 잠근다.

void transfer(
    Account& from,
    Account& to,
    Money amount
)
{
    std::scoped_lock lock(
        from.mutex_,
        to.mutex_
    );

    from.balance_ -= amount;
    to.balance_ += amount;
}

공유 뮤텍스

std::shared_mutex는 여러 읽기 스레드가 공유 잠금을 동시에 획득하고 쓰기 스레드는 배타 잠금을 획득하게 한다.

#include <shared_mutex>

class ConfigurationStore {
    mutable std::shared_mutex mutex_;
    Configuration configuration_;

public:
    Configuration read() const
    {
        std::shared_lock lock(mutex_);

        return configuration_;
    }

    void write(
        Configuration configuration
    )
    {
        std::unique_lock lock(mutex_);

        configuration_ =
            std::move(configuration);
    }
};

읽기가 많고 쓰기가 드문 구조에서 사용할 수 있지만, 일반 뮤텍스보다 내부 비용이 높을 수 있고 구현체의 공정성 정책에 따라 읽기 또는 쓰기 기아가 발생할 수 있다.

재귀 뮤텍스

std::recursive_mutex는 같은 스레드가 동일 뮤텍스를 여러 번 잠글 수 있게 한다.

std::recursive_mutex mutex;

void outer()
{
    std::lock_guard lock(mutex);

    inner();
}

void inner()
{
    std::lock_guard lock(mutex);

    modify();
}

재귀 잠금이 필요하다는 사실은 함수 사이의 잠금 책임이 불명확하다는 신호일 수 있다. 가능하면 잠금이 필요한 내부 함수와 이미 잠긴 상태에서 호출하는 구현을 분리할 수 있다.

교착 상태

교착 상태는 여러 실행 주체가 서로가 보유한 자원을 기다리며 영원히 진행하지 못하는 상태다.

대표적인 조건은 다음과 같다.

  • 자원이 동시에 하나의 주체에만 할당된다.
  • 자원을 보유한 채 다른 자원을 기다린다.
  • 자원을 강제로 회수할 수 없다.
  • 자원 대기 관계가 순환한다.

일관된 잠금 순서

void operation()
{
    std::lock_guard first_lock(
        first_mutex
    );

    std::lock_guard second_lock(
        second_mutex
    );
}

모든 코드가 동일한 순서로 뮤텍스를 잠그면 순환 대기를 줄일 수 있다.

잠금 범위 축소

Data snapshot;

{
    std::lock_guard lock(mutex);

    snapshot = shared_data;
}

perform_expensive_calculation(
    snapshot
);

공유 데이터를 복사한 뒤 오래 걸리는 작업은 잠금 밖에서 수행한다.

콜백 중 잠금 유지 회피

void notify_all()
{
    std::lock_guard lock(mutex);

    for (auto& callback : callbacks) {
        callback();
    }
}

콜백이 다시 같은 객체에 접근하거나 다른 잠금을 획득하면 교착과 재진입 문제가 생길 수 있다.

void notify_all()
{
    std::vector<Callback> snapshot;

    {
        std::lock_guard lock(mutex);

        snapshot = callbacks;
    }

    for (auto& callback : snapshot) {
        callback();
    }
}

조건 변수

조건 변수는 공유 상태가 특정 조건을 만족할 때까지 스레드를 대기시키고, 상태가 변경됐을 때 하나 이상의 대기 스레드를 깨운다.

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mutex;
std::condition_variable condition;
std::queue<Task> tasks;
bool finished = false;

생산자는 잠금 안에서 상태를 변경한 뒤 알림을 보낸다.

void submit(Task task)
{
    {
        std::lock_guard lock(mutex);

        tasks.push(
            std::move(task)
        );
    }

    condition.notify_one();
}

소비자는 조건을 검사하며 기다린다.

void worker()
{
    while (true) {
        Task task;

        {
            std::unique_lock lock(mutex);

            condition.wait(
                lock,
                [] {
                    return finished ||
                           !tasks.empty();
                }
            );

            if (finished &&
                tasks.empty()) {
                return;
            }

            task =
                std::move(
                    tasks.front()
                );

            tasks.pop();
        }

        task();
    }
}

조건 술어

조건 변수는 이유 없이 깨어나는 spurious wakeup을 허용할 수 있다. 또한 알림을 받은 뒤 다른 스레드가 먼저 상태를 소비할 수 있으므로 깨어난 뒤 조건을 다시 검사해야 한다.

std::unique_lock lock(mutex);

while (tasks.empty()) {
    condition.wait(lock);
}

술어 오버로드는 이 반복 구조를 내부적으로 표현한다.

condition.wait(
    lock,
    [] {
        return !tasks.empty();
    }
);

알림과 잠금

공유 상태는 일반적으로 잠금 안에서 수정해야 하지만, 알림은 잠금 밖에서 수행할 수 있다.

{
    std::lock_guard lock(mutex);

    ready = true;
}

condition.notify_all();

깨어난 스레드가 즉시 같은 뮤텍스를 획득할 가능성을 높여 불필요한 대기를 줄일 수 있다.

시간 제한 대기

using namespace
    std::chrono_literals;

std::unique_lock lock(mutex);

bool ready =
    condition.wait_for(
        lock,
        500ms,
        [] {
            return data_ready;
        }
    );

if (!ready) {
    handle_timeout();
}

절대 시점을 사용하는 wait_until()도 제공된다.

세마포어

C++20의 세마포어는 내부 카운터를 통해 동시에 자원을 사용하는 실행 수를 제한하거나 생산된 항목 수를 전달한다.

#include <semaphore>

std::counting_semaphore<8>
    available_slots(4);
void use_limited_resource()
{
    available_slots.acquire();

    auto release =
        std::scope_exit(
            [] {
                available_slots.release();
            }
        );

    perform_operation();
}

동시에 최대 네 개의 실행만 보호 구역에 들어간다.

이진 세마포어

std::binary_semaphore signal(0);

void producer()
{
    prepare_data();

    signal.release();
}

void consumer()
{
    signal.acquire();

    consume_data();
}

이진 세마포어는 0과 1 범위의 카운터를 사용해 신호를 전달한다. 뮤텍스와 달리 획득한 스레드와 반환하는 스레드가 같아야 한다는 소유 개념을 갖지 않는다.

래치

std::latch는 정해진 수의 작업이 완료될 때까지 기다리는 일회성 동기화 도구다.

#include <latch>
#include <thread>
#include <vector>

constexpr int worker_count = 4;

std::latch completed(
    worker_count
);

std::vector<std::jthread> workers;

for (int index = 0;
     index < worker_count;
     ++index) {
    workers.emplace_back(
        [index, &completed] {
            process_partition(index);

            completed.count_down();
        }
    );
}

completed.wait();

combine_results();

카운터가 0에 도달하면 모든 대기가 해제된다. 한 번 0이 된 래치는 다시 초기화할 수 없다.

작업이 완료됐음을 알리면서 동시에 기다릴 수 있다.

completed.arrive_and_wait();

배리어

std::barrier는 여러 스레드가 반복되는 계산 단계의 경계에서 서로를 기다리게 한다.

#include <barrier>

std::barrier phase_barrier(
    worker_count,
    [] {
        complete_phase();
    }
);
void worker(int index)
{
    for (int phase = 0;
         phase < phase_count;
         ++phase) {
        process_phase(
            index,
            phase
        );

        phase_barrier.arrive_and_wait();
    }
}

모든 참여자가 현재 단계에 도착하면 완료 함수가 실행되고 다음 단계가 시작된다.

참여를 영구적으로 중단하는 스레드는 arrive_and_drop()을 사용할 수 있다.

if (no_more_work()) {
    phase_barrier.arrive_and_drop();

    return;
}

std::call_once

프로그램 실행 중 특정 초기화 함수를 정확히 한 번 수행하려면 std::once_flagstd::call_once를 사용할 수 있다.

#include <mutex>

std::once_flag initialization_flag;

void initialize()
{
    std::call_once(
        initialization_flag,
        [] {
            initialize_runtime();
        }
    );
}

여러 스레드가 동시에 호출해도 성공적으로 완료된 초기화는 한 번만 실행된다. 실행 함수가 예외를 던지면 완료된 것으로 간주되지 않아 이후 호출이 다시 시도할 수 있다.

함수 지역 정적 객체도 스레드 안전한 일회성 초기화를 제공한다.

Runtime& runtime()
{
    static Runtime instance;

    return instance;
}

원자적 연산

std::atomic<T>는 특정 객체에 대한 읽기, 쓰기와 읽기-수정-쓰기를 원자적으로 수행한다. 원자적 연산은 다른 스레드가 연산의 중간 상태를 관찰하지 못하게 하고, 선택한 메모리 순서에 따라 다른 메모리 접근과의 관계를 형성한다.[96]

#include <atomic>

std::atomic<int> counter = 0;

void increment()
{
    counter.fetch_add(
        1,
        std::memory_order_relaxed
    );
}

fetch_add는 기존 값을 읽고 증가된 값을 저장하는 작업을 하나의 원자적 읽기-수정-쓰기 연산으로 수행한다.

기본 연산

std::atomic<int> value = 0;

value.store(42);

int current =
    value.load();

int previous =
    value.exchange(100);

복합 연산도 제공된다.

counter.fetch_add(1);
counter.fetch_sub(1);

counter += 10;
counter -= 5;

비교 후 교환

compare-and-exchange는 원자적 객체가 예상 값과 같으면 새 값으로 바꾸고, 다르면 현재 값을 예상 변수에 기록한다.

std::atomic<int> state = 0;

int expected = 0;

bool changed =
    state.compare_exchange_strong(
        expected,
        1
    );

성공하면 state1이 된다. 실패하면 expected가 실제 상태값으로 갱신된다.

반복 갱신에 사용할 수 있다.

void update_maximum(int candidate)
{
    int current =
        maximum.load(
            std::memory_order_relaxed
        );

    while (current < candidate &&
           !maximum.compare_exchange_weak(
               current,
               candidate,
               std::memory_order_relaxed
           )) {
    }
}

compare_exchange_weak는 값이 같아도 허위 실패할 수 있으므로 일반적으로 반복문에 사용한다. compare_exchange_strong은 한 번의 비교 시도나 허위 실패를 원하지 않는 상황에 사용할 수 있다.

std::atomic_ref

std::atomic_ref<T>는 기존 객체를 원자적으로 접근하는 참조형 래퍼다.

#include <atomic>

int value = 0;

std::atomic_ref<int> atomic_value(
    value
);

atomic_value.fetch_add(1);

atomic_ref가 해당 객체를 참조하는 동안에는 같은 객체에 대한 모든 접근이 요구되는 원자성 규칙을 따라야 한다. 일반 비원자 접근과 혼합하면 데이터 경쟁이 발생할 수 있다.

원자적 대기와 알림

C++20은 원자적 객체의 값이 변경될 때까지 대기하는 wait, notify_one, notify_all을 제공한다.

std::atomic<int> state = 0;

void consumer()
{
    state.wait(0);

    use_ready_data();
}
void producer()
{
    prepare_data();

    state.store(
        1,
        std::memory_order_release
    );

    state.notify_all();
}

wait(0)은 값이 0인 동안 대기하며 알림 뒤에도 실제 값을 다시 검사한다.

메모리 순서

std::memory_order는 원자적 연산 자체의 원자성뿐 아니라 일반 메모리 접근과의 순서·가시성 관계를 지정한다. 현재 표준 작업 초안의 주요 순서는 relaxed, acquire, release, acq_rel, seq_cst다.[97]

메모리 순서일반적인 의미
memory_order_relaxed원자성만 보장하며 다른 메모리 접근의 동기화 순서를 만들지 않음
memory_order_acquire이후 연산이 획득 연산 앞으로 이동하지 않도록 하고 대응 release 효과를 관찰
memory_order_release이전 연산이 해제 연산 뒤로 이동하지 않도록 하고 정보를 게시
memory_order_acq_rel읽기-수정-쓰기에서 acquire와 release를 함께 적용
memory_order_seq_cst순차 일관 원자 연산 사이에 하나의 전역적 총순서를 추가

메모리 순서를 생략한 원자적 연산은 기본적으로 memory_order_seq_cst를 사용한다.

relaxed

단순 통계 카운터처럼 다른 데이터의 게시와 관계없이 원자적 값 자체만 정확히 갱신하면 되는 경우 사용할 수 있다.

std::atomic<unsigned long long>
    request_count = 0;

void record_request()
{
    request_count.fetch_add(
        1,
        std::memory_order_relaxed
    );
}

각 증가가 유실되지 않는다는 원자성은 보장하지만, 다른 일반 메모리 쓰기를 다른 스레드에 게시하는 동기화 수단은 아니다.

release와 acquire

생산자가 일반 데이터를 완성한 뒤 release 저장으로 준비 상태를 게시하고, 소비자가 acquire 읽기로 상태를 관찰할 수 있다.

struct Data {
    int first;
    int second;
};

Data data;
std::atomic<bool> ready = false;

void producer()
{
    data.first = 10;
    data.second = 20;

    ready.store(
        true,
        std::memory_order_release
    );
}

void consumer()
{
    while (!ready.load(
               std::memory_order_acquire
           )) {
        std::this_thread::yield();
    }

    use(data);
}

소비자의 acquire 읽기가 생산자의 release 저장 값을 관찰하면, 생산자가 그 이전에 수행한 data 쓰기가 소비자에서 관찰 가능해진다.

ready만 원자적이고 data는 일반 객체지만, 설정과 읽기 사이에 동기화 관계가 있으므로 이 사용에서는 데이터 경쟁이 발생하지 않는다.

acq_rel

읽기와 쓰기를 동시에 수행하는 원자적 연산이 이전 상태를 획득하고 새 상태를 게시해야 할 때 사용한다.

std::atomic<int> active_users = 0;

int previous =
    active_users.fetch_add(
        1,
        std::memory_order_acq_rel
    );

구체적인 필요에 따라 성공과 실패의 메모리 순서를 compare-and-exchange에 별도로 지정할 수 있다.

seq_cst

순차 일관 연산은 모든 seq_cst 연산이 하나의 전역 순서에 놓인 것처럼 추가 보장을 제공한다.

std::atomic<bool> first = false;
std::atomic<bool> second = false;

void write_first()
{
    first.store(
        true,
        std::memory_order_seq_cst
    );
}

void write_second()
{
    second.store(
        true,
        std::memory_order_seq_cst
    );
}

기본 순서는 추론하기 쉽지만 특정 하드웨어에서 더 강한 장벽을 요구할 수 있다. 더 약한 메모리 순서는 정확한 동기화 증명이 있을 때 사용해야 한다.

lock-free와 wait-free

원자적 타입을 사용한다고 해서 구현이 반드시 하드웨어 잠금 없이 동작하는 것은 아니다. 타입과 플랫폼에 따라 내부 잠금을 사용할 수 있다.

std::atomic<long long> value;

bool lock_free =
    value.is_lock_free();

컴파일 시간 속성을 확인할 수 있는 타입도 있다.

static_assert(
    std::atomic<int>::
        is_always_lock_free
);

실제 보장은 구현체와 대상 플랫폼에 따라 달라질 수 있다. 표준의 lock-free 매크로 값은 해당 원자 타입이 항상, 가끔 또는 전혀 lock-free가 아닌지를 나타낸다.[98]

일반적으로 다음 진행 보장을 구분한다.

  • blocking: 다른 스레드가 진행하거나 잠금을 해제해야 현재 연산이 진행될 수 있다.
  • lock-free: 시스템 전체에서는 유한한 단계 안에 적어도 하나의 연산이 진행한다.
  • wait-free: 각 개별 연산이 유한한 단계 안에 완료된다.

lock-free는 자동으로 더 빠르거나 더 단순하다는 뜻이 아니다. 메모리 회수, ABA 문제, 메모리 순서와 진행 보장 검증이 필요해 뮤텍스 기반 구조보다 구현과 유지가 어려울 수 있다.

ABA 문제

compare-and-exchange 기반 알고리즘에서 값이 A에서 B로 바뀌었다가 다시 A가 되면 단순 값 비교만으로는 중간 변경을 감지하지 못할 수 있다.

스레드 1이 값 A 읽기
↓
스레드 2가 A를 B로 변경
↓
스레드 2가 B를 다시 A로 변경
↓
스레드 1의 compare_exchange에서 A가 일치
↓
중간 변경을 알지 못하고 연산 성공

포인터 기반 lock-free 구조에서는 같은 주소가 해제 후 새 객체에 재사용될 수 있다. 해결 방식으로 버전 태그, 위험 포인터, epoch 기반 회수와 read-copy-update 계열 구조 등이 사용될 수 있지만, 이러한 일반 메모리 회수 체계 전체가 C++23 표준 라이브러리에 하나의 통합된 고수준 컨테이너로 제공되는 것은 아니다.

false sharing

서로 다른 스레드가 서로 다른 객체를 수정하더라도 같은 하드웨어 캐시 라인에 배치되면 캐시 일관성 트래픽이 반복될 수 있다.

struct Counters {
    std::atomic<int> first = 0;
    std::atomic<int> second = 0;
};

firstsecond에 데이터 경쟁은 없지만 같은 캐시 라인을 공유하면 성능이 저하될 수 있다.

std::hardware_destructive_interference_size를 사용할 수 있는 구현에서는 간섭을 줄이는 정렬 기준으로 사용할 수 있다.

#include <new>

struct alignas(
    std::hardware_destructive_interference_size
) Counter {
    std::atomic<int> value = 0;
};

struct Counters {
    Counter first;
    Counter second;
};

실제 캐시 구조와 이 상수의 지원 품질은 플랫폼과 구현체에 영향을 받는다.

메시지 전달

공유 가변 상태를 여러 스레드가 직접 수정하는 대신, 한 실행 주체가 메시지 큐를 소유하고 다른 주체가 작업 메시지를 전달하도록 구성할 수 있다.

class MessageQueue {
    std::mutex mutex_;
    std::condition_variable_any condition_;
    std::queue<Message> messages_;

public:
    void push(Message message)
    {
        {
            std::lock_guard lock(mutex_);

            messages_.push(
                std::move(message)
            );
        }

        condition_.notify_one();
    }

    std::optional<Message>
    pop(std::stop_token stop)
    {
        std::unique_lock lock(mutex_);

        bool ready =
            condition_.wait(
                lock,
                stop,
                [&] {
                    return !messages_.empty();
                }
            );

        if (!ready) {
            return std::nullopt;
        }

        Message message =
            std::move(
                messages_.front()
            );

        messages_.pop();

        return message;
    }
};
void worker(
    std::stop_token stop,
    MessageQueue& queue
)
{
    while (auto message =
               queue.pop(stop)) {
        process(*message);
    }
}

메시지 전달은 모든 데이터 경쟁을 자동으로 제거하지는 않는다. 메시지 내부가 공유 객체를 가리키거나 여러 소비자가 같은 상태를 수정하면 별도 동기화가 필요하다. 그러나 소유권을 작업 스레드로 이전하거나 불변 데이터만 전달하면 공유 상태의 범위를 줄일 수 있다.

C++ Core Guidelines도 저수준 스레드와 원자적 연산을 직접 넓게 사용하는 것보다 가능한 경우 메시지 전달, 병렬 알고리즘과 상위 수준 구조를 사용하도록 권장한다.[99]

태스크 기반 실행

스레드는 실행 자원 자체를 나타내지만 태스크는 수행할 작업과 그 결과를 나타낸다. 태스크 기반 구조에서는 호출자가 구체적인 스레드를 직접 소유하지 않고 실행 시스템이 작업을 적절한 스레드에서 수행할 수 있다.

표준 C++23에는 완전한 범용 스레드 풀 인터페이스가 없지만 std::async, std::future, std::promisestd::packaged_task가 결과 중심 비동기 실행의 기본 요소를 제공한다.

std::future

std::future<T>는 나중에 준비될 T 결과 또는 예외를 나타낸다.

#include <future>

std::future<int> future =
    std::async(
        std::launch::async,
        [] {
            return calculate();
        }
    );

perform_other_work();

int result =
    future.get();

get()은 결과가 준비될 때까지 기다린 뒤 값을 반환한다. 작업에서 예외가 발생했다면 get()이 그 예외를 다시 던진다.

std::future<int> future =
    std::async(
        std::launch::async,
        []() -> int {
            throw CalculationError();

            return 0;
        }
    );

try {
    int result =
        future.get();
}
catch (const CalculationError&) {
    handle_error();
}

future의 결과는 일반적으로 한 번만 get()할 수 있다.

if (future.valid()) {
    int result =
        future.get();
}

대기

future.wait();

int result =
    future.get();

시간 제한을 둘 수 있다.

using namespace
    std::chrono_literals;

std::future_status status =
    future.wait_for(100ms);

if (status ==
    std::future_status::ready) {
    use(future.get());
}
else if (status ==
         std::future_status::timeout) {
    handle_timeout();
}

std::shared_future

여러 소비자가 같은 결과를 읽어야 한다면 std::shared_future를 사용할 수 있다.

std::future<Configuration> source =
    load_configuration_async();

std::shared_future<Configuration>
    shared =
        source.share();
std::jthread first(
    [shared] {
        use_first(shared.get());
    }
);

std::jthread second(
    [shared] {
        use_second(shared.get());
    }
);

shared_future::get()은 여러 번 호출할 수 있으며 여러 복사본이 같은 공유 상태를 관찰한다.

std::async

std::async는 호출 가능한 객체를 비동기적으로 실행하고 결과를 std::future로 반환한다.

std::future<Image> image =
    std::async(
        std::launch::async,
        load_image,
        path
    );

실행 정책을 지정하지 않으면 구현체가 비동기 실행 또는 지연 실행을 선택할 수 있다.

auto future =
    std::async(
        calculate
    );

명시적인 비동기 실행을 요구하려면 std::launch::async를 사용한다.

auto future =
    std::async(
        std::launch::async,
        calculate
    );

지연 실행은 get() 또는 wait()를 호출한 스레드에서 작업을 수행한다.

auto future =
    std::async(
        std::launch::deferred,
        calculate
    );

perform_other_work();

int result =
    future.get();

std::async는 간단한 독립 작업에 사용할 수 있지만 실행 스레드 수, 큐, 작업 우선순위와 스케줄러를 세밀하게 제어하는 범용 스레드 풀 인터페이스는 아니다.

future 소멸과 대기

특정 std::async 비동기 실행에서 마지막 관련 퓨처가 파괴될 때 작업 완료를 기다릴 수 있다.

std::async(
    std::launch::async,
    first_task
);

std::async(
    std::launch::async,
    second_task
);

임시 퓨처가 각 문장 끝에서 파괴되며 대기하면 두 작업이 의도와 달리 순차적으로 실행될 수 있다.

auto first =
    std::async(
        std::launch::async,
        first_task
    );

auto second =
    std::async(
        std::launch::async,
        second_task
    );

first.get();
second.get();

std::promise

std::promise<T>는 한 실행 주체가 나중에 값 또는 예외를 설정하고, 다른 실행 주체가 연결된 std::future<T>로 결과를 받게 한다.

std::promise<int> promise;

std::future<int> future =
    promise.get_future();

std::jthread worker(
    [promise =
         std::move(promise)]() mutable {
        try {
            int result =
                calculate();

            promise.set_value(result);
        }
        catch (...) {
            promise.set_exception(
                std::current_exception()
            );
        }
    }
);

int result =
    future.get();

값이 없는 완료 신호에는 std::promise<void>를 사용할 수 있다.

std::promise<void> completed;

std::future<void> future =
    completed.get_future();

completed.set_value();

future.wait();

프라미스가 값을 설정하지 않은 채 파괴되면 연결된 퓨처는 broken promise 오류를 관찰할 수 있다.

std::packaged_task

std::packaged_task<Signature>는 호출 가능한 객체를 저장하고 실행 결과 또는 예외를 퓨처 공유 상태에 기록한다.

std::packaged_task<int(int, int)>
    task(
        [](int left, int right) {
            return left + right;
        }
    );

std::future<int> future =
    task.get_future();

std::jthread worker(
    std::move(task),
    20,
    22
);

int result =
    future.get();

작업 큐에 이동 전용 태스크를 저장할 때 사용할 수 있다.

using Task =
    std::packaged_task<void()>;

class TaskQueue {
    std::mutex mutex_;
    std::condition_variable condition_;
    std::queue<Task> tasks_;

public:
    void push(Task task)
    {
        {
            std::lock_guard lock(mutex_);

            tasks_.push(
                std::move(task)
            );
        }

        condition_.notify_one();
    }
};

간단한 스레드 풀

표준 C++23에는 범용 표준 스레드 풀 타입이 없지만 std::jthread, 조건 변수와 작업 큐를 조합해 제한된 풀을 만들 수 있다.

#include <condition_variable>
#include <functional>
#include <future>
#include <mutex>
#include <queue>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>

class ThreadPool {
    using Task =
        std::move_only_function<
            void()
        >;

    std::mutex mutex_;
    std::condition_variable_any
        condition_;

    std::queue<Task> tasks_;
    std::vector<std::jthread>
        workers_;

public:
    explicit ThreadPool(
        std::size_t worker_count
    )
    {
        workers_.reserve(
            worker_count
        );

        for (std::size_t index = 0;
             index < worker_count;
             ++index) {
            workers_.emplace_back(
                [this](
                    std::stop_token stop
                ) {
                    worker_loop(stop);
                }
            );
        }
    }

    template<class Function>
    auto submit(Function&& function)
    {
        using Result =
            std::invoke_result_t<
                Function&
            >;

        std::packaged_task<Result()>
            packaged(
                std::forward<Function>(
                    function
                )
            );

        std::future<Result> future =
            packaged.get_future();

        {
            std::lock_guard lock(mutex_);

            tasks_.emplace(
                [task =
                     std::move(packaged)
                ]() mutable {
                    task();
                }
            );
        }

        condition_.notify_one();

        return future;
    }

private:
    void worker_loop(
        std::stop_token stop
    )
    {
        while (true) {
            Task task;

            {
                std::unique_lock lock(
                    mutex_
                );

                bool ready =
                    condition_.wait(
                        lock,
                        stop,
                        [this] {
                            return !tasks_.empty();
                        }
                    );

                if (!ready) {
                    return;
                }

                task =
                    std::move(
                        tasks_.front()
                    );

                tasks_.pop();
            }

            task();
        }
    }
};
ThreadPool pool(4);

auto first =
    pool.submit(
        [] {
            return load_texture(
                "first.png"
            );
        }
    );

auto second =
    pool.submit(
        [] {
            return load_texture(
                "second.png"
            );
        }
    );

Texture first_texture =
    first.get();

Texture second_texture =
    second.get();

실제 범용 스레드 풀은 다음 문제도 다뤄야 한다.

  • 종료 시 대기 중 작업의 처리 정책
  • 작업 우선순위
  • 작업 도난
  • 큐 포화와 역압
  • 중지 전달
  • 장시간 차단 작업과 계산 작업의 분리
  • 스레드별 저장 공간
  • 재귀 제출과 교착
  • 작업 예외
  • CPU 친화도와 NUMA
  • 성능 계측과 추적

병렬 알고리즘

표준 알고리즘의 일부는 실행 정책을 받아 구현체가 여러 실행 주체나 벡터화를 사용할 수 있게 한다.

#include <algorithm>
#include <execution>
#include <vector>

std::vector<float> input(
    1'000'000
);

std::vector<float> output(
    input.size()
);

std::transform(
    std::execution::par,
    input.begin(),
    input.end(),
    output.begin(),
    [](float value) {
        return expensive_transform(
            value
        );
    }
);

실행 정책

std::execution::seq;
std::execution::par;
std::execution::par_unseq;
std::execution::unseq;
  • seq는 순차 실행을 요청한다.
  • par는 여러 실행 스레드에서 병렬 실행할 수 있게 한다.
  • unseq는 하나의 스레드 안에서도 호출이 비순차적으로 겹치거나 벡터화될 수 있게 한다.
  • par_unseq는 병렬 실행과 비순차 실행을 모두 허용한다.

병렬 알고리즘의 함수 객체 호출은 실행 정책에 따라 여러 스레드에서 호출되거나 하나의 스레드에서 서로 비순차적으로 겹칠 수 있다.[100]

공유 상태 수정

int total = 0;

std::for_each(
    std::execution::par,
    values.begin(),
    values.end(),
    [&](int value) {
        total += value;
    }
);

여러 호출이 total을 동시에 수정해 데이터 경쟁이 발생할 수 있다.

축약 알고리즘을 사용할 수 있다.

int total =
    std::reduce(
        std::execution::par,
        values.begin(),
        values.end(),
        0
    );

연산의 결합 법칙

std::reducestd::accumulate와 달리 연산의 결합 순서를 바꿀 수 있다. 부동소수점 덧셈은 수학적으로 완전한 결합 법칙을 만족하지 않으므로 결과의 마지막 비트가 실행 방식에 따라 달라질 수 있다.

double result =
    std::reduce(
        std::execution::par,
        values.begin(),
        values.end(),
        0.0
    );

재현 가능한 수치 결과가 필요하면 연산 순서를 고정하거나 별도의 안정적 축약 전략을 설계해야 한다.

비순차 실행 제약

par_unseq 또는 unseq에서 호출되는 코드는 벡터화에 안전해야 한다.

std::for_each(
    std::execution::par_unseq,
    values.begin(),
    values.end(),
    [](int value) {
        std::lock_guard lock(mutex);

        process(value);
    }
);

비순차 실행 문맥에서 잠금과 같은 벡터화에 안전하지 않은 동기화 작업을 사용하면 실행이 진행하지 못하거나 요구 사항을 위반할 수 있다. 각 원소가 독립된 데이터만 처리하도록 구성할 수 있다.

코루틴

C++20 코루틴은 함수 실행을 중간 지점에서 중단하고 나중에 다시 재개할 수 있게 하는 언어 기능이다. 코루틴은 스레드가 아니며 자체적으로 병렬 실행이나 운영체제 이벤트 루프를 제공하지 않는다.

코루틴 함수는 다음 키워드 가운데 하나를 사용한다.

  • co_await
  • co_yield
  • co_return
Task<int> calculate_async()
{
    int first =
        co_await read_first_async();

    int second =
        co_await read_second_async();

    co_return first + second;
}

co_await가 작업을 중단하면 현재 스레드는 다른 일을 수행할 수 있다. 이후 awaiter 또는 비동기 런타임이 코루틴 핸들을 재개해야 한다.

코루틴 변환

컴파일러는 코루틴 함수의 지역 상태와 재개 위치를 보존할 수 있는 코루틴 프레임을 생성한다. 반환 타입의 promise_type이 코루틴의 생성, 중단, 반환값과 예외 처리 방식을 정의한다.

코루틴의 일반적인 수명
  1. 코루틴 함수 호출
  2. 코루틴 프레임 생성
  3. promise 객체 생성
  4. 초기 중단 여부 결정
  5. 함수 본문 실행
  6. co_await에서 중단 가능
  7. 외부 이벤트가 코루틴 재개
  8. co_return 또는 본문 종료
  9. 최종 중단
  10. 코루틴 프레임 파괴

co_await

co_await expression은 표현식을 awaiter로 변환하고 준비 상태를 검사한다.

개념적으로 awaiter는 다음 연산을 제공한다.

struct Awaiter {
    bool await_ready();

    void await_suspend(
        std::coroutine_handle<>
            coroutine
    );

    Result await_resume();
};
  • await_ready()가 참이면 중단하지 않는다.
  • 거짓이면 await_suspend()가 호출되어 재개 방법을 등록한다.
  • 코루틴이 재개되면 await_resume() 결과가 co_await 표현식의 결과가 된다.

간단한 수동 이벤트

#include <coroutine>

class ManualEvent {
    std::coroutine_handle<>
        waiter_;

    bool ready_ = false;

public:
    bool await_ready() const noexcept
    {
        return ready_;
    }

    void await_suspend(
        std::coroutine_handle<>
            coroutine
    ) noexcept
    {
        waiter_ = coroutine;
    }

    void await_resume() const noexcept
    {
    }

    void set()
    {
        ready_ = true;

        if (waiter_) {
            auto waiter = waiter_;
            waiter_ = nullptr;

            waiter.resume();
        }
    }
};

이 예제는 개념 설명용이며 여러 스레드에서 안전하지 않고 한 명의 대기자만 지원한다. 실제 이벤트 타입은 동시 접근, 코루틴 프레임 수명, 중지와 중복 신호를 처리해야 한다.

co_yield

co_yield는 값을 외부 소비자에게 제공하고 코루틴을 중단한다.

#include <generator>

std::generator<int>
count_to(int end)
{
    for (int value = 1;
         value <= end;
         ++value) {
        co_yield value;
    }
}
for (int value :
     count_to(5)) {
    process(value);
}

생성기는 값을 하나씩 지연 생성하지만 일반적으로 그 자체로 다른 스레드에서 병렬 실행되지는 않는다.

코루틴과 스레드

코루틴을 재개하는 스레드는 awaiter와 런타임 구현이 결정한다.

Task<void> operation()
{
    co_await schedule_on(
        background_scheduler
    );

    perform_background_work();

    co_await schedule_on(
        ui_scheduler
    );

    update_ui();

    co_return;
}

C++20 코루틴 문법 자체에는 schedule_on이나 표준 Task 타입이 없다. 이러한 타입은 라이브러리나 애플리케이션 런타임이 제공해야 한다.

코루틴 수명

코루틴 프레임이 외부 객체에 대한 참조를 보관하면 해당 객체가 코루틴 완료까지 살아 있어야 한다.

Task<void> process_async(
    const std::string& text
)
{
    co_await delay();

    use(text);
}
void start()
{
    std::string text = "C++";

    process_async(text);

    // 반환된 태스크가 계속 실행되는데
    // text가 먼저 파괴될 수 있다.
}

값을 코루틴 프레임으로 이동하거나 상위 소유 객체가 작업 완료까지 수명을 유지해야 한다.

Task<void> process_async(
    std::string text
)
{
    co_await delay();

    use(text);
}

코루틴과 중지

협력적 중지 토큰을 비동기 작업에 전달할 수 있다.

Task<Result> load_async(
    Path path,
    std::stop_token stop
)
{
    if (stop.stop_requested()) {
        co_return std::unexpected(
            Error::Cancelled
        );
    }

    auto data =
        co_await read_async(
            path,
            stop
        );

    co_return parse(data);
}

중지 요청이 언제 관찰되고 어떤 오류 또는 완료 상태로 전달되는지는 비동기 타입의 계약이 정해야 한다.

콜백 기반 비동기 실행

코루틴이 없는 비동기 API는 완료 콜백을 등록할 수 있다.

read_async(
    socket,
    buffer,
    [](ReadResult result) {
        if (!result) {
            handle_error(
                result.error()
            );

            return;
        }

        process(
            result.value()
        );
    }
);

여러 작업을 순차적으로 연결하면 중첩이 깊어질 수 있다.

connect_async(
    address,
    [&](Connection connection) {
        authenticate_async(
            connection,
            credentials,
            [&](Session session) {
                read_async(
                    session,
                    [&](Data data) {
                        process(data);
                    }
                );
            }
        );
    }
);

코루틴은 같은 흐름을 순차 코드에 가까운 형태로 표현할 수 있다.

Task<void> run_async()
{
    Connection connection =
        co_await connect_async(
            address
        );

    Session session =
        co_await authenticate_async(
            connection,
            credentials
        );

    Data data =
        co_await read_async(
            session
        );

    process(data);
}

코루틴은 제어 흐름 표현을 개선하지만 실제 작업 스케줄링, I/O 등록, 타이머, 오류·중지 전달과 프레임 소유권은 라이브러리 계층이 담당한다.

구조화된 동시성

구조화된 동시성은 비동기 작업의 수명을 이를 시작한 렉시컬 또는 논리적 범위에 연결하려는 설계 원칙이다. 상위 작업은 자신이 시작한 하위 작업이 완료되거나 중지될 때까지 수명을 관리하며, 오류와 중지 요청도 작업 계층을 따라 전달한다.

구조화되지 않은 분리 스레드는 호출 범위를 벗어난 뒤에도 계속 실행될 수 있다.

void start_background_work()
{
    std::thread(
        [] {
            perform_long_operation();
        }
    ).detach();
}

작업이 어떤 객체를 사용할 수 있는지, 프로그램 종료 때 어떻게 중지되는지, 오류를 어디로 전달하는지가 인터페이스에서 드러나지 않는다.

구조화된 형태에서는 상위 범위가 하위 작업을 소유한다.

void process_all()
{
    std::jthread first(
        [](
            std::stop_token stop
        ) {
            process_first(stop);
        }
    );

    std::jthread second(
        [](
            std::stop_token stop
        ) {
            process_second(stop);
        }
    );

    // 함수가 끝나기 전에 두 작업이 중지·조인된다.
}

보다 일반적인 비동기 런타임에서는 작업 그룹이나 비동기 범위가 하위 sender 또는 코루틴을 추적할 수 있다.

sender와 receiver

sender/receiver는 비동기 작업을 값처럼 구성하고, 해당 작업이 전달할 수 있는 값·오류·중지 완료 신호를 타입과 프로토콜로 표현하는 모델이다.

P2300은 스케줄러, sender, receiver와 비동기 알고리즘을 중심으로 표준 비동기 실행 모델을 제안했으며, 최신 C++ 작업 초안에는 std::execution 영역의 관련 기능이 포함되어 있다. 이는 C++23 표준 라이브러리 기능이 아니라 차기 표준 작업에 속하므로 실제 사용 가능 여부는 컴파일러와 라이브러리 구현 상태를 확인해야 한다.[101][102]

sender는 비동기 작업의 설명을 나타내며 작업이 반드시 즉시 시작된다는 뜻은 아니다.

auto work =
    std::execution::just(21)
    | std::execution::then(
          [](int value) {
              return value * 2;
          }
      );

작업을 실제 receiver에 연결하고 시작하는 단계가 별도로 존재한다.

auto result =
    std::this_thread::sync_wait(
        std::move(work)
    );

구체적인 이름과 지원 범위는 최종 표준과 구현에 따라 달라질 수 있으므로 이 코드는 차기 표준 모델을 설명하기 위한 개념적 예시다.

완료 채널

sender 작업은 일반적으로 다음 완료 신호를 구분한다.

  • 값 완료
  • 오류 완료
  • 중지 완료
비동기 작업
├── 값 전달
├── 오류 전달
└── 중지 전달

퓨처 하나에 값 또는 예외를 저장하는 모델보다 중지와 여러 값 타입, 실행 환경과 스케줄러 조합을 일반화하려는 구조다.

스케줄러

스케줄러는 작업을 어떤 실행 문맥에서 시작할지를 나타낸다.

auto work =
    std::execution::schedule(
        scheduler
    )
    | std::execution::then(
          [] {
              return calculate();
          }
      );

같은 작업 알고리즘을 스레드 풀, 이벤트 루프, GPU 큐와 같은 다른 실행 자원에 연결할 수 있는 기반을 목표로 한다.

sender 구성

auto first =
    load_first_async();

auto second =
    load_second_async();

auto combined =
    std::execution::when_all(
        std::move(first),
        std::move(second)
    )
    | std::execution::then(
          [](First first_value,
             Second second_value) {
              return combine(
                  std::move(first_value),
                  std::move(second_value)
              );
          }
      );

콜백 중첩 없이 비동기 작업 그래프를 알고리즘 조합으로 표현한다.

동기 실행과 비동기 실행의 선택

비동기 실행이 항상 더 빠른 것은 아니다. 작업을 큐에 넣고 스레드를 깨우며 상태를 동기화하는 비용이 실제 작업보다 클 수 있다.

작은 작업

auto future =
    std::async(
        std::launch::async,
        [] {
            return 1 + 1;
        }
    );

단순 덧셈에 새 비동기 작업을 만드는 비용이 계산 비용보다 훨씬 클 수 있다.

충분히 큰 독립 작업

auto terrain =
    pool.submit(
        [] {
            return generate_terrain();
        }
    );

auto navigation =
    pool.submit(
        [] {
            return build_navigation();
        }
    );

auto audio =
    pool.submit(
        [] {
            return decode_audio();
        }
    );

작업이 충분히 크고 서로 독립적이면 실행 시간이 겹쳐 전체 대기 시간을 줄일 수 있다.

I/O 대기

네트워크와 파일 I/O처럼 대부분의 시간이 외부 완료를 기다리는 작업은 코루틴과 이벤트 기반 비동기 I/O로 적은 수의 스레드에서 많은 연결을 관리할 수 있다.

Task<Response> handle_request(
    Request request
)
{
    Data data =
        co_await database.query(
            request.key
        );

    co_return make_response(data);
}

CPU 계산 작업은 작업 큐와 스레드 풀에, 대기 중심 I/O는 이벤트 루프와 코루틴에 배치하는 식으로 실행 자원을 분리할 수 있다.

스레드 수

std::thread::hardware_concurrency()는 구현체가 추정하는 동시 실행 가능 스레드 수를 반환한다.

unsigned int count =
    std::thread::
        hardware_concurrency();

if (count == 0) {
    count = 1;
}

이 값은 힌트이며 실제 물리 코어 수나 최적 작업 스레드 수를 보장하지 않는다.

적절한 스레드 수는 다음 요소에 따라 달라진다.

  • 물리 코어와 논리 코어
  • 작업의 CPU 사용률
  • I/O 대기 비율
  • 다른 프로세스와 공유하는 시스템
  • NUMA 구조
  • 캐시와 메모리 대역폭
  • 작업 크기
  • 실시간 응답 요구
  • 스레드별 메모리 사용량

CPU 중심 작업은 논리 처리 장치 수 부근에서 시작해 측정할 수 있고, I/O 중심 작업은 더 많은 동시 작업을 허용할 수 있다.

작업 분할

너무 큰 작업은 병렬성을 충분히 활용하지 못하고, 너무 작은 작업은 큐와 동기화 비용을 증가시킨다.

void process_parallel(
    std::span<Item> items,
    ThreadPool& pool
)
{
    std::size_t partition_size =
        1024;

    std::vector<
        std::future<void>
    > futures;

    for (std::size_t begin = 0;
         begin < items.size();
         begin += partition_size) {
        std::size_t end =
            std::min(
                begin + partition_size,
                items.size()
            );

        futures.push_back(
            pool.submit(
                [part =
                     items.subspan(
                         begin,
                         end - begin
                     )
                ] {
                    process_partition(
                        part
                    );
                }
            )
        );
    }

    for (auto& future : futures) {
        future.get();
    }
}

작업량이 불균일하면 고정 분할보다 동적 작업 큐나 작업 도난이 효율적일 수 있다.

작업 도난

작업 도난 스케줄러는 각 작업 스레드가 자신의 큐를 처리하고, 큐가 비면 다른 스레드의 큐에서 작업을 가져온다.

작업 스레드 A
├── 로컬 작업
├── 로컬 작업
└── 로컬 작업

작업 스레드 B
└── 큐가 비어 있음
    ↓
A의 큐에서 작업 일부를 가져옴

재귀적으로 하위 작업을 만드는 그래프 처리, 렌더링과 분할 정복 알고리즘에서 부하 균형을 개선할 수 있다. 표준 C++23은 범용 작업 도난 스레드 풀을 직접 제공하지 않으므로 구현체 또는 외부 라이브러리의 실행 시스템을 사용할 수 있다.

예외 처리

스레드의 최상위 함수 밖으로 예외가 빠져나가면 std::terminate가 호출된다.

std::thread thread(
    [] {
        throw WorkerError();
    }
);

스레드 내부에서 예외를 처리하거나 std::promise, 퓨처 또는 메시지로 전달해야 한다.

std::promise<void> promise;

std::future<void> future =
    promise.get_future();

std::jthread worker(
    [promise =
         std::move(promise)]() mutable {
        try {
            perform_work();

            promise.set_value();
        }
        catch (...) {
            promise.set_exception(
                std::current_exception()
            );
        }
    }
);

future.get();

태스크 기반 구조는 결과와 예외를 하나의 완료 채널로 전달하기 쉽다.

동시성 객체의 수명

비동기 작업은 이를 시작한 함수보다 오래 실행될 수 있으므로 캡처한 객체와 참조의 수명을 명확히 해야 한다.

this 캡처

class Downloader {
public:
    void start()
    {
        std::thread(
            [this] {
                download();
            }
        ).detach();
    }

private:
    void download();
};

Downloader 객체가 작업보다 먼저 파괴되면 this가 유효하지 않다.

공동 소유가 실제 의미에 맞는 경우 shared_ptr로 수명을 유지할 수 있다.

class Downloader :
    public std::enable_shared_from_this<
        Downloader
    > {
public:
    void start()
    {
        auto self =
            shared_from_this();

        executor.submit(
            [self] {
                self->download();
            }
        );
    }
};

또는 작업을 상위 객체가 명시적으로 소유하고 소멸 전에 중지·조인하도록 구성할 수 있다.

class Downloader {
    std::jthread worker_;

public:
    void start()
    {
        worker_ =
            std::jthread(
                [this](
                    std::stop_token stop
                ) {
                    download(stop);
                }
            );
    }

private:
    void download(
        std::stop_token stop
    );
};

멤버 파괴 순서는 선언의 역순이므로 작업 스레드가 사용하는 다른 멤버보다 worker_를 뒤에 선언하거나 명시적인 종료 순서를 구현해야 한다.

class Downloader {
    NetworkClient client_;
    Buffer buffer_;
    std::jthread worker_;

public:
    ~Downloader()
    {
        worker_.request_stop();

        if (worker_.joinable()) {
            worker_.join();
        }
    }
};

worker_가 먼저 종료된 뒤 buffer_client_가 파괴된다.

불변 데이터

공유 데이터를 수정하지 않으면 동기화 요구를 크게 줄일 수 있다.

struct Configuration {
    int worker_count;
    std::string endpoint;
};

std::shared_ptr<
    const Configuration
> configuration =
    std::make_shared<
        const Configuration
    >(
        Configuration {
            .worker_count = 4,
            .endpoint =
                "server.example"
        }
    );

여러 스레드가 같은 불변 객체를 읽는 것은 별도 쓰기 동기화를 필요로 하지 않는다.

새 설정은 새 객체로 만들어 원자적으로 교체할 수 있다.

std::atomic<
    std::shared_ptr<
        const Configuration
    >
> configuration;

void update_configuration(
    Configuration next
)
{
    configuration.store(
        std::make_shared<
            const Configuration
        >(
            std::move(next)
        )
    );
}

소비자는 일관된 스냅샷을 얻는다.

auto snapshot =
    configuration.load();

use(*snapshot);

스레드 지역 저장소

thread_local 객체는 스레드마다 별도 인스턴스를 가진다.

thread_local ScratchBuffer
    scratch_buffer;

void process()
{
    auto memory =
        scratch_buffer.acquire(
            4096
        );

    use(memory);
}

공유 잠금 없이 스레드별 임시 저장 공간을 사용할 수 있다.

그러나 작업이 여러 스레드 사이에서 이동하는 태스크 시스템에서는 하나의 논리 작업이 실행 단계마다 다른 thread-local 상태를 볼 수 있다. 논리 작업 상태는 태스크나 코루틴 프레임에 명시적으로 저장할 수 있다.

동시성 오류

데이터 경쟁

int value = 0;

void first()
{
    value = 10;
}

void second()
{
    value = 20;
}

동기화 없는 충돌 쓰기다.

교착 상태

first_mutex.lock();
second_mutex.lock();

다른 스레드가 반대 순서로 잠그면 순환 대기가 발생할 수 있다.

라이브락

두 스레드가 서로 양보하며 계속 상태를 변경하지만 실제 작업을 완료하지 못할 수 있다.

while (true) {
    if (first_mutex.try_lock()) {
        if (second_mutex.try_lock()) {
            break;
        }

        first_mutex.unlock();
    }

    std::this_thread::yield();
}

두 스레드가 같은 패턴으로 동시에 반복하면 서로 같은 시점에 양보할 수 있다. 무작위 또는 지수 백오프, 일관된 잠금 순서와 std::lock 계열을 사용할 수 있다.

기아

특정 스레드가 필요한 잠금이나 실행 시간을 계속 얻지 못할 수 있다. 표준 뮤텍스가 모든 플랫폼에서 엄격한 공정성을 보장하는 것은 아니다.

우선순위 역전

낮은 우선순위 스레드가 잠금을 보유하고 높은 우선순위 스레드가 이를 기다리는 동안, 중간 우선순위 작업이 낮은 우선순위 스레드의 실행을 막을 수 있다. 실시간 시스템에서는 운영체제의 우선순위 상속 뮤텍스나 잠금 없는 데이터 전달 등 플랫폼별 기능을 고려할 수 있다.

놓친 알림

공유 조건 없이 알림 자체만 사건으로 취급하면 알림이 대기 시작 전에 발생해 영원히 기다릴 수 있다.

condition.notify_one();

// 이후 다른 스레드가 wait를 시작하면
// 이전 알림은 저장된 메시지처럼 남아 있지 않는다.

항상 뮤텍스로 보호된 상태 술어와 함께 사용해야 한다.

거짓 공유

언어적으로 올바르지만 캐시 라인 경합으로 성능이 급격히 떨어질 수 있다.

과도한 스레드 생성

for (const Item& item : items) {
    threads.emplace_back(
        [item] {
            process(item);
        }
    );
}

원소 수만큼 운영체제 스레드를 만들면 생성 비용, 스택 메모리와 스케줄링 오버헤드가 커질 수 있다. 고정 크기 작업 풀이나 병렬 알고리즘을 사용할 수 있다.

동시성 진단

동시성 오류는 실행 순서에 따라 간헐적으로 나타날 수 있다. 일반 디버거 외에 동적 경쟁 검사기와 실행 추적 도구를 사용할 수 있다.

ThreadSanitizer를 지원하는 Clang 또는 GCC 환경에서는 다음과 같이 빌드할 수 있다.

c++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=thread \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

AddressSanitizer와 UndefinedBehaviorSanitizer를 별도 구성으로 사용할 수 있다.

c++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=address,undefined \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

ThreadSanitizer와 AddressSanitizer는 같은 실행 파일에서 함께 지원되지 않는 경우가 있으므로 별도 빌드로 운영할 수 있다.

진단에는 다음 정보가 유용하다.

  • 작업 식별자
  • 실행 스레드 식별자
  • 큐 대기 시간
  • 작업 시작과 종료 시점
  • 잠금 대기 시간
  • 중지 요청과 관찰 시점
  • 코루틴 중단과 재개 위치
  • 스케줄러와 실행 문맥
  • 원자적 상태 전이
  • 작업 부모와 자식 관계
struct TaskTrace {
    TaskId id;
    TaskId parent;
    std::thread::id thread;
    TimePoint queued_at;
    TimePoint started_at;
    TimePoint completed_at;
};

동시성 설계 원칙

C++ 동시성 코드는 다음과 같은 방향으로 구성할 수 있다.

  1. 공유 가변 상태를 최소화한다.
  2. 가능한 데이터는 불변 값이나 소유권 이전 메시지로 전달한다.
  3. 공유 상태와 이를 보호하는 뮤텍스를 같은 타입에 캡슐화한다.
  4. 직접 lock()unlock()보다 RAII 잠금 객체를 사용한다.
  5. 여러 뮤텍스는 일관된 순서 또는 std::scoped_lock으로 잠근다.
  6. 잠금 안에서 외부 콜백이나 오래 걸리는 작업을 수행하지 않는다.
  7. 조건 변수는 항상 공유 상태 술어와 함께 사용한다.
  8. 스레드 수보다 작업 단위와 작업 큐를 중심으로 설계한다.
  9. 범위에 속한 스레드에는 std::jthread와 협력적 중지를 고려한다.
  10. 원자적 연산은 하나의 값에 대한 원자성만으로 충분한 경우에 사용한다.
  11. 여러 객체의 불변 조건은 뮤텍스나 더 높은 수준의 트랜잭션으로 보호한다.
  12. 약한 메모리 순서는 정확한 happens-before 관계를 설명할 수 있을 때만 사용한다.
  13. 코루틴이 참조하는 객체와 코루틴 프레임의 수명을 명확하게 관리한다.
  14. 분리 스레드보다 상위 범위가 하위 작업의 완료와 중지를 소유하는 구조를 사용한다.
  15. 병렬 알고리즘의 함수 객체는 공유 상태에 의존하지 않게 구성한다.
  16. 실행 정책과 병렬화는 실제 작업 크기를 측정한 뒤 적용한다.
  17. 경쟁 검사기와 부하 테스트를 사용해 간헐적 오류를 찾는다.
  18. lock-free 구조는 검증 가능한 필요가 있을 때 제한된 내부 구성 요소로 사용한다.

종합 예제

다음 예제는 std::jthread, 중지 토큰, 조건 변수, 퓨처와 작업 큐를 사용해 범위 기반 작업 실행기를 구성한다.

#include <condition_variable>
#include <future>
#include <mutex>
#include <queue>
#include <stop_token>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>

class Executor {
    using Task =
        std::move_only_function<
            void()
        >;

    std::mutex mutex_;

    std::condition_variable_any
        condition_;

    std::queue<Task> tasks_;

    std::vector<std::jthread>
        workers_;

public:
    explicit Executor(
        std::size_t worker_count
    )
    {
        workers_.reserve(
            worker_count
        );

        for (std::size_t index = 0;
             index < worker_count;
             ++index) {
            workers_.emplace_back(
                [this](
                    std::stop_token stop
                ) {
                    run_worker(stop);
                }
            );
        }
    }

    Executor(const Executor&) =
        delete;

    Executor& operator=(
        const Executor&
    ) = delete;

    template<class Function>
    auto submit(Function&& function)
        -> std::future<
            std::invoke_result_t<
                Function&
            >
        >
    {
        using Result =
            std::invoke_result_t<
                Function&
            >;

        std::packaged_task<Result()>
            packaged(
                std::forward<Function>(
                    function
                )
            );

        std::future<Result> future =
            packaged.get_future();

        {
            std::lock_guard lock(mutex_);

            tasks_.emplace(
                [task =
                     std::move(packaged)
                ]() mutable {
                    task();
                }
            );
        }

        condition_.notify_one();

        return future;
    }

    void request_stop()
    {
        for (std::jthread& worker :
             workers_) {
            worker.request_stop();
        }

        condition_.notify_all();
    }

private:
    void run_worker(
        std::stop_token stop
    )
    {
        while (true) {
            Task task;

            {
                std::unique_lock lock(
                    mutex_
                );

                bool available =
                    condition_.wait(
                        lock,
                        stop,
                        [this] {
                            return !tasks_.empty();
                        }
                    );

                if (!available) {
                    return;
                }

                task =
                    std::move(
                        tasks_.front()
                    );

                tasks_.pop();
            }

            task();
        }
    }
};

실행기는 작업 결과와 예외를 퓨처로 전달한다.

int main()
{
    Executor executor(4);

    auto texture =
        executor.submit(
            [] {
                return load_texture(
                    "surface.png"
                );
            }
        );

    auto mesh =
        executor.submit(
            [] {
                return load_mesh(
                    "model.glb"
                );
            }
        );

    try {
        Texture loaded_texture =
            texture.get();

        Mesh loaded_mesh =
            mesh.get();

        create_render_object(
            std::move(loaded_mesh),
            std::move(loaded_texture)
        );
    }
    catch (const std::exception& error) {
        report(error.what());

        executor.request_stop();

        return 1;
    }

    return 0;
}

이 구조에서 각 기능은 다음 역할을 맡는다.

  • std::jthread가 작업 스레드의 수명을 실행기 객체에 연결한다.
  • std::stop_token이 종료 요청을 작업 스레드에 전달한다.
  • std::condition_variable_any가 작업이 없는 스레드를 대기시킨다.
  • 뮤텍스가 작업 큐를 보호한다.
  • std::packaged_task가 호출 결과와 예외를 공유 상태에 기록한다.
  • std::future가 호출자에게 완료 결과를 전달한다.
  • 작업 자체는 운영체제 스레드가 아니라 실행기에 제출되는 값으로 표현된다.
  • 실행기가 파괴되면 작업 스레드가 중지 요청을 받고 조인된다.

다만 이 예제는 학습용 기본 구조다. 작업이 남아 있는 상태에서 중지를 요청했을 때 남은 작업을 폐기할지 완료할지, 실행기 내부에서 다시 제출한 뒤 퓨처를 기다릴 때 교착을 어떻게 피할지, 큐 용량과 우선순위를 어떻게 관리할지는 별도의 정책이 필요하다.

C++의 동시성 모델은 스레드를 생성하는 문법에만 머물지 않는다. 언어 차원의 메모리 모델이 공유 데이터의 의미를 규정하고, 뮤텍스와 원자적 연산이 happens-before 관계를 형성하며, RAII가 잠금과 스레드 수명을 관리한다. 퓨처와 태스크는 결과와 오류 전달을 실행 자원에서 분리하고, 코루틴은 중단 가능한 비동기 제어 흐름을 순차적인 코드 형태로 표현한다.

저수준 스레드와 원자적 연산은 운영체제, 게임 엔진, 런타임과 고성능 라이브러리에 필요한 직접 제어를 제공한다. 상위 애플리케이션에서는 메시지 큐, 작업 실행기, 병렬 알고리즘과 구조화된 비동기 범위를 사용해 공유 상태와 수명 문제를 더 작은 경계 안에 제한할 수 있다.

모듈과 프로그램 구성

C++ 프로그램은 하나의 소스 파일에 모든 코드를 작성할 수도 있지만, 실제 프로젝트에서는 선언과 정의를 여러 파일과 논리적 구성 요소로 나눈다. 전통적인 C++ 프로그램은 헤더 파일과 소스 파일, 전처리기, 번역 단위와 링커를 중심으로 구성되며, C++20부터는 언어 차원의 모듈이 추가되어 선언과 정의를 번역 단위 사이에 공유하는 새로운 방법을 제공한다.

C++의 프로그램 구성은 다음 계층으로 나눌 수 있다.

  • 문자로 작성된 소스 파일
  • 전처리 결과로 형성되는 번역 단위
  • 각각 독립적으로 번역되는 목적 파일
  • 목적 파일과 라이브러리를 결합하는 링킹
  • 이름공간과 클래스에 의한 논리적 이름 구성
  • 헤더 또는 모듈을 통한 선언 공유
  • 빌드 시스템에 의한 의존성 분석과 컴파일 순서 결정
  • 실행 파일, 정적 라이브러리와 공유 라이브러리로 이루어진 배포 단위

전통적인 헤더 방식에서는 #include가 다른 파일의 내용을 현재 소스에 문자 단위로 삽입한다. 반면 모듈의 import는 이미 번역된 모듈 인터페이스의 선언을 가져오는 언어 기능이다. 모듈은 헤더를 완전히 제거하는 별도의 언어 모드라기보다, 기존 번역 단위와 링킹 모델 위에 추가된 프로그램 구성 수단이다.[103][104]

C++ 프로그램 구성 계층
  • C++ 프로그램
    • 소스 구성
      • 소스 파일
      • 헤더 파일
      • 모듈 인터페이스
      • 모듈 구현 단위
    • 번역 구성
      • 전처리
      • 번역 단위
      • 템플릿 인스턴스화
      • 목적 파일 생성
    • 연결 구성
      • 외부 연결 이름
      • 정적 라이브러리
      • 공유 라이브러리
      • 실행 파일
    • 논리적 구성
      • 이름공간
      • 클래스와 템플릿
      • 패키지·라이브러리
      • 모듈과 모듈 파티션

소스 파일과 번역 단위

C++ 표준은 특정 파일 확장자를 규정하지 않는다. 실제 도구 체계에서는 관례적으로 다음과 같은 확장자를 사용한다.

종류일반적인 확장자
C++ 소스 파일.cpp, .cc, .cxx, .C
헤더 파일.h, .hpp, .hxx, .hh
모듈 인터페이스 파일.cppm, .ixx, .mpp, .ccm
모듈 구현 파일.cpp, .cc, .cxx

모듈 인터페이스의 확장자와 처리 방법은 표준이 아니라 컴파일러와 빌드 시스템의 관례에 속한다.

다음은 두 소스 파일로 나뉜 전통적인 프로그램이다.

// main.cpp

#include "calculator.hpp"

#include <iostream>

int main()
{
    int result =
        calculator::add(
            20,
            22
        );

    std::cout
        << result
        << '\n';
}
// calculator.cpp

#include "calculator.hpp"

namespace calculator {

int add(int left, int right)
{
    return left + right;
}

}

헤더에는 다른 번역 단위에서 사용할 선언을 둔다.

// calculator.hpp

#pragma once

namespace calculator {

int add(int left, int right);

}

main.cppcalculator.cpp는 각각 독립적인 번역 단위를 형성한다. 각 번역 단위는 별도로 컴파일되고, 이후 링커가 두 목적 파일을 결합한다.

main.cpp
↓ 전처리와 컴파일
main.o

calculator.cpp
↓ 전처리와 컴파일
calculator.o

main.o + calculator.o
↓ 링킹
실행 파일

번역 단위

전처리 지시문이 처리된 뒤 컴파일러가 하나의 단위로 번역하는 프로그램 조각을 번역 단위라고 한다.

// source.cpp

#include "first.hpp"
#include "second.hpp"

int function()
{
    return value;
}

개념적으로 #include된 헤더 내용이 삽입된 전체 결과가 하나의 번역 단위를 이룬다.

`first.hpp` 내용
+
`second.hpp` 내용
+
`source.cpp`의 나머지 내용
=
하나의 번역 단위

프로젝트의 소스 파일 수와 번역 단위 수는 일반적으로 비슷하지만, 하나의 소스 파일을 서로 다른 매크로나 컴파일 옵션으로 여러 번 번역할 수도 있고, 생성된 소스가 번역 단위에 포함될 수도 있다.

전처리기

C++ 전처리기는 본격적인 구문 분석 전에 포함 지시문, 매크로와 조건부 컴파일 등을 처리한다.

#include <vector>

#define APPLICATION_VERSION 23

#if defined(_WIN32)
#include <windows.h>
#elif defined(__linux__)
#include <unistd.h>
#endif

주요 전처리 지시문은 다음과 같다.

지시문용도
#include다른 파일의 전처리 토큰 삽입
#define매크로 정의
#undef매크로 정의 제거
#if, #ifdef, #ifndef조건부 전처리
#elif, #else, #endif조건 분기
#error번역 오류 발생
#line보고되는 파일명과 줄 번호 조정
#pragma구현체별 지시

객체형 매크로

#define DEFAULT_WIDTH 1280
#define DEFAULT_HEIGHT 720

Window window(
    DEFAULT_WIDTH,
    DEFAULT_HEIGHT
);

언어 타입을 갖는 상수로 표현할 수 있다면 constexpr 변수를 사용할 수 있다.

inline constexpr int
    default_width = 1280;

inline constexpr int
    default_height = 720;

constexpr 변수는 범위와 타입 검사를 가지며 디버거와 언어 도구가 더 직접적으로 이해할 수 있다.

함수형 매크로

#define SQUARE(value) ((value) * (value))

매크로 인자는 여러 번 평가될 수 있다.

int value = 3;

// int result = SQUARE(value++);
// value++가 두 번 평가될 수 있다.

함수 또는 함수 템플릿으로 대체할 수 있다.

template<class T>
constexpr T square(T value)
{
    return value * value;
}

조건부 컴파일

#if defined(_WIN32)

NativeWindow
create_native_window()
{
    return create_win32_window();
}

#elif defined(__APPLE__)

NativeWindow
create_native_window()
{
    return create_cocoa_window();
}

#elif defined(__linux__)

NativeWindow
create_native_window()
{
    return create_wayland_window();
}

#else
#error Unsupported platform
#endif

플랫폼 차이를 하나의 소스 파일 안에서 분기할 수도 있지만, 구현 파일 자체를 플랫폼별로 나누고 빌드 시스템이 하나만 선택하도록 구성할 수도 있다.

platform/
├── Window.hpp
├── WindowWin32.cpp
├── WindowCocoa.mm
└── WindowWayland.cpp

공개 헤더에는 공통 인터페이스를 두고 플랫폼별 구현은 별도 번역 단위에 둔다.

헤더 파일

헤더 파일은 여러 번역 단위가 공유해야 하는 선언, 타입 정의, 인라인 함수와 템플릿 정의를 보관하는 데 사용된다.

// texture.hpp

#pragma once

#include <filesystem>
#include <memory>

class Texture {
public:
    Texture(
        const Texture&
    ) = delete;

    Texture& operator=(
        const Texture&
    ) = delete;

    Texture(Texture&&) noexcept;
    Texture& operator=(Texture&&) noexcept;

    ~Texture();

    static std::unique_ptr<Texture>
    load(
        const std::filesystem::path& path
    );

    int width() const noexcept;
    int height() const noexcept;

private:
    class Impl;

    explicit Texture(
        std::unique_ptr<Impl> impl
    );

    std::unique_ptr<Impl> impl_;
};

구현은 소스 파일에 둔다.

// texture.cpp

#include "texture.hpp"

class Texture::Impl {
public:
    NativeTexture handle;
    int width;
    int height;
};

Texture::~Texture() = default;

int Texture::width() const noexcept
{
    return impl_->width;
}

이 구조는 공개 인터페이스와 구현 세부 사항을 분리한다.

포함 보호

같은 헤더가 한 번역 단위에 여러 번 포함되면 동일 선언과 정의가 반복될 수 있다.

// first.hpp
#include "common.hpp"

// second.hpp
#include "common.hpp"

// source.cpp
#include "first.hpp"
#include "second.hpp"

헤더 가드는 헤더 내용이 한 번만 활성화되게 한다.

#ifndef TECHPEDIA_TEXTURE_HPP
#define TECHPEDIA_TEXTURE_HPP

class Texture {
};

#endif

많은 컴파일러는 #pragma once도 지원한다.

#pragma once

class Texture {
};

#pragma once는 널리 지원되지만 표준 전처리 지시문은 아니다. 헤더 가드는 순수한 표준 전처리 기능만으로 구성된다.

자기 완결적 헤더

헤더는 가능하면 자신을 가장 먼저 포함해도 정상적으로 번역되는 자기 완결적 구조를 갖는다.

// renderer.hpp

#pragma once

#include <memory>
#include <span>

class Command;

class Renderer {
public:
    void submit(
        std::span<const Command>
            commands
    );
};

헤더가 필요한 표준 타입을 직접 포함하지 않고 다른 헤더의 우연한 포함 순서에 의존하면 사용 위치에 따라 번역 결과가 달라질 수 있다.

// renderer.hpp

// <vector>를 직접 포함하지 않았다.
class Renderer {
    std::vector<Command> commands_;
};

전방 선언

포인터나 참조만 필요한 클래스는 전체 정의 대신 전방 선언할 수 있다.

class Window;

class Renderer {
    Window& window_;

public:
    explicit Renderer(Window& window);
};

값 멤버나 기반 클래스, sizeof처럼 완전한 타입이 필요한 위치에서는 정의가 필요하다.

#include "window.hpp"

class Application {
    Window window_;
};

전방 선언은 헤더 의존성을 줄일 수 있지만 모든 타입을 무조건 전방 선언하는 것이 적절한 것은 아니다. 별칭 템플릿과 표준 라이브러리 타입을 구현체의 선언 방식에 맞지 않게 직접 전방 선언하면 올바르지 않다.

// namespace std {
//     template<class T>
//     class vector;
// }

// 표준 라이브러리 선언을 사용자가 임의로 재작성하면 안 된다.

필요한 표준 헤더를 포함해야 한다.

포함 순서

소스 파일은 자신이 구현하는 헤더를 먼저 포함할 수 있다.

#include "renderer.hpp"

#include "command.hpp"
#include "window.hpp"

#include <algorithm>
#include <vector>

이 방식은 공개 헤더가 다른 포함 파일에 숨겨진 의존성을 갖고 있는지 발견하는 데 도움이 된다.

선언과 정의

선언은 이름과 타입을 프로그램에 알리고, 정의는 해당 개체를 실제로 제공한다.

// 함수 선언
int add(int left, int right);

// 함수 정의
int add(int left, int right)
{
    return left + right;
}

변수도 선언과 정의를 구분할 수 있다.

// settings.hpp

extern int global_volume;
// settings.cpp

int global_volume = 100;

헤더에서 일반 전역 변수를 그대로 정의하면 이를 포함한 각 번역 단위에 정의가 생길 수 있다.

// settings.hpp

int global_volume = 100;

C++17의 인라인 변수는 여러 번역 단위에 동일한 정의를 둘 수 있도록 한다.

inline int global_volume = 100;

전역 가변 상태 자체가 필요하지 않다면 설정 객체나 상위 애플리케이션 객체에 포함할 수 있다.

단일 정의 규칙

단일 정의 규칙은 프로그램 안에서 선언과 정의가 몇 번 나타날 수 있는지를 규정한다. 비인라인 함수와 외부 연결 변수는 일반적으로 전체 프로그램에 하나의 정의만 가져야 한다. 클래스 정의, 템플릿과 인라인 함수 등은 요구 조건을 만족하는 동일한 정의가 여러 번역 단위에 나타날 수 있다.

// first.cpp
int calculate()
{
    return 10;
}
// second.cpp

// int calculate()
// {
//     return 20;
// }

// 동일한 외부 연결 함수의 두 번째 정의가 된다.

링커가 중복 심볼 오류를 보고할 수 있지만 모든 ODR 위반이 반드시 진단되는 것은 아니다.

헤더의 인라인 함수

// math.hpp

#pragma once

inline int square(int value)
{
    return value * value;
}

여러 번역 단위가 같은 헤더를 포함해도 인라인 함수의 동일한 정의를 가질 수 있다.

클래스 정의 안에서 정의한 멤버 함수는 일반적으로 암시적으로 인라인 함수가 된다.

class Counter {
    int value_ = 0;

public:
    int value() const noexcept
    {
        return value_;
    }
};

inline은 컴파일러가 반드시 함수 호출을 기계어 수준에서 펼쳐야 한다는 명령이 아니다. 프로그램 구성 관점에서는 여러 번역 단위에 정의를 둘 수 있게 하는 ODR 의미가 중요하다.

템플릿 정의

템플릿은 인스턴스화 지점에서 정의를 볼 수 있어야 하는 경우가 많으므로 헤더에 정의한다.

#pragma once

template<class T>
T maximum(T left, T right)
{
    return left < right
        ? right
        : left;
}

지원할 특수화가 고정돼 있다면 소스 파일에 구현하고 명시적 인스턴스화를 제공할 수 있다.

// maximum.cpp

template<class T>
T maximum(T left, T right)
{
    return left < right
        ? right
        : left;
}

template int maximum<int>(
    int,
    int
);

template double maximum<double>(
    double,
    double
);

연결

연결은 서로 다른 범위 또는 번역 단위에 나타난 이름이 같은 개체를 가리키는지를 나타낸다.

일반적으로 다음 연결 종류를 구분한다.

  • 연결 없음
  • 내부 연결
  • 외부 연결
  • 모듈 연결

연결 없음

지역 변수는 다른 범위의 같은 이름과 연결되지 않는다.

void first()
{
    int value = 10;
}

void second()
{
    int value = 20;
}

value는 서로 다른 객체다.

내부 연결

이름공간 범위의 static 함수와 변수는 해당 번역 단위 내부에서만 연결된다.

static int helper(
    int value
)
{
    return value * 2;
}

익명 이름공간도 번역 단위 내부 구현을 표현한다.

namespace {

class ParserState {
};

int helper(int value)
{
    return value * 2;
}

}

소스 파일 안에서만 사용하는 이름에는 익명 이름공간을 사용할 수 있다.

외부 연결

일반적인 이름공간 범위 함수와 extern 변수는 다른 번역 단위에서도 같은 개체를 가리킬 수 있다.

// library.hpp

void initialize_library();

extern int library_version;

모듈 연결

모듈에 속하지만 내보내지 않은 일부 이름은 같은 모듈의 다른 단위에서 사용할 수 있는 모듈 연결을 가질 수 있다. 외부에서 모듈을 가져오는 사용자는 해당 이름을 사용할 수 없다.[105]

// engine-internal.cppm

module engine;

void initialize_backend()
{
}

같은 이름의 모듈 구현 안에서는 사용할 수 있지만 import engine;만 한 외부 코드는 내보내지 않은 이름을 볼 수 없다.

이름공간

이름공간은 관련된 이름을 하나의 논리적 범위에 묶는다.

namespace techpedia::parser {

class Token {
};

class Lexer {
};

SyntaxTree parse(
    std::string_view source
);

}

외부에서는 정규화된 이름을 사용한다.

techpedia::parser::Lexer lexer;

techpedia::parser::SyntaxTree
    tree =
        techpedia::parser::parse(
            source
        );

모듈과 이름공간은 서로 다른 개념이다.

  • 이름공간은 이름 충돌을 방지하고 논리적 범위를 구성한다.
  • 모듈은 선언의 가시성, 소유와 번역 단위 사이의 공유를 구성한다.
  • 하나의 모듈에 여러 이름공간이 존재할 수 있다.
  • 같은 이름공간의 선언이 여러 모듈이나 헤더에 분산될 수 있다.
// 모듈 이름은 graphics이고,
// 공개 C++ 이름은 atanara::graphics에 있다.

export module graphics;

export namespace atanara::graphics {

class Device {
};

}

모듈 이름을 내부 C++ 이름공간과 반드시 같게 만들 필요는 없지만, 프로젝트 규칙으로 대응시키면 구성을 이해하기 쉬울 수 있다.

헤더 중심 프로그램 구성

전통적인 C++ 라이브러리는 공개 헤더와 구현 소스 파일을 나눈다.

library/
├── include/
│   └── techpedia/
│       ├── Parser.hpp
│       ├── Token.hpp
│       └── SyntaxTree.hpp
└── src/
    ├── Parser.cpp
    ├── Lexer.cpp
    └── SyntaxTree.cpp

사용자는 공개 포함 경로를 통해 헤더를 가져온다.

#include <techpedia/Parser.hpp>

라이브러리 내부 구현 파일은 공개되지 않는 헤더를 추가로 사용할 수 있다.

library/
├── include/
│   └── techpedia/
│       └── Parser.hpp
└── src/
    ├── Parser.cpp
    ├── ParserState.hpp
    └── TokenBuffer.hpp

공개 헤더와 비공개 헤더

공개 헤더에 포함된 다른 헤더는 라이브러리 사용자의 번역 과정에도 영향을 준다.

// Renderer.hpp

#include <vulkan/vulkan.h>
#include <vector>
#include <unordered_map>

구현 세부 사항을 직접 노출하면 사용자가 Vulkan 헤더와 내부 컨테이너에 의존하게 된다.

PImpl을 사용해 공개 의존성을 줄일 수 있다.

// Renderer.hpp

#pragma once

#include <memory>

class Window;

class Renderer {
    class Impl;

    std::unique_ptr<Impl> impl_;

public:
    explicit Renderer(Window& window);
    ~Renderer();

    Renderer(Renderer&&) noexcept;
    Renderer& operator=(Renderer&&) noexcept;

    void render();
};
// Renderer.cpp

#include "Renderer.hpp"

#include <vulkan/vulkan.h>
#include <unordered_map>
#include <vector>

class Renderer::Impl {
    // Vulkan과 내부 자료구조
};

PImpl은 공개 헤더 의존성과 ABI 노출을 줄이지만, 동적 할당과 간접 접근이 추가될 수 있다.

전처리 포함 모델의 특징

#include는 파일을 독립된 의미 단위로 가져오는 것이 아니라 현재 번역 단위에 텍스트를 삽입한다.

// feature.hpp

#ifdef ENABLE_ADVANCED_FEATURE
void advanced_feature();
#endif

포함하는 번역 단위의 매크로 상태에 따라 같은 헤더의 내용이 달라질 수 있다.

// first.cpp
#define ENABLE_ADVANCED_FEATURE
#include "feature.hpp"

// second.cpp
#include "feature.hpp"

이 특성은 조건부 API를 만들 수 있게 하지만, 동일한 헤더가 번역 단위마다 다른 선언을 제공해 ODR 문제를 일으킬 수도 있다.

매크로 누출

헤더가 정의한 매크로는 포함 뒤에도 전처리 상태에 남는다.

// platform.hpp
#define interface struct

다른 라이브러리의 식별자와 충돌할 수 있다.

#include "platform.hpp"

// class interface {
// };

필요한 범위를 마친 뒤 #undef할 수 있지만, 가능한 기능은 타입과 함수, constexpr 값으로 표현할 수 있다.

반복 구문 분석

대형 헤더가 여러 소스 파일에 포함되면 각 번역 단위에서 같은 선언과 템플릿을 반복해서 전처리하고 구문 분석해야 한다.

LargeHeader.hpp
├── 포함 → SourceA.cpp에서 분석
├── 포함 → SourceB.cpp에서 분석
├── 포함 → SourceC.cpp에서 분석
└── 포함 → SourceD.cpp에서 분석

전처리 헤더, 통합 빌드와 모듈 등이 빌드 시간 개선에 사용될 수 있다.

전처리 헤더

전처리 헤더는 자주 사용되며 변경이 드문 헤더 집합을 미리 처리해 후속 번역에서 재사용하는 구현체 기능이다.

// pch.hpp

#pragma once

#include <algorithm>
#include <filesystem>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>

각 소스 파일은 이를 먼저 포함하도록 구성할 수 있다.

#include "pch.hpp"

#include "Renderer.hpp"

전처리 헤더는 C++ 표준 언어 기능이 아니며 컴파일러와 빌드 시스템별 설정이 필요하다.

전처리 헤더가 너무 많은 프로젝트 내부 헤더를 포함하면 작은 변경이 전체 캐시를 무효화하고 실제 의존성을 숨길 수 있다. 주로 안정적인 표준 및 외부 라이브러리 헤더를 넣고 프로젝트별 헤더는 별도로 관리할 수 있다.

통합 빌드

통합 빌드는 여러 구현 파일을 하나의 상위 소스에서 포함해 적은 수의 대형 번역 단위로 컴파일하는 방식이다.

// unity.cpp

#include "Renderer.cpp"
#include "Audio.cpp"
#include "Physics.cpp"
#include "Network.cpp"

번역 단위 사이에 반복되던 헤더 분석을 줄일 수 있지만 다음 문제가 생길 수 있다.

  • 원래 별도 번역 단위였던 내부 이름의 충돌
  • 매크로 상태의 누출
  • 변경 시 더 큰 단위 재컴파일
  • 병렬 컴파일 단위 감소
  • 헤더의 자기 완결성 문제 은폐
  • 구현 파일을 포함하는 비표준 프로젝트 관례

통합 빌드는 배포 빌드와 전체 빌드 시간을 줄일 수 있지만 개발 중 증분 빌드 특성과 함께 측정해야 한다.

모듈

C++20 모듈은 선언과 정의를 번역 단위 사이에서 공유하기 위한 언어 기능이다. 모듈은 헤더의 일부 사용 사례를 대체하지만 기존 소스 파일, 목적 파일과 링커를 제거하지 않는다.

C++ 표준에서 모듈 단위는 모듈 선언을 포함하는 번역 단위이고, 이름 있는 모듈은 같은 모듈 이름을 가진 모듈 단위들의 집합이다.[106]

다음은 가장 작은 모듈 인터페이스다.

// math.cppm

export module math;

export int add(
    int left,
    int right
)
{
    return left + right;
}

사용자는 모듈을 가져온다.

// main.cpp

import math;

#include <iostream>

int main()
{
    std::cout
        << add(20, 22)
        << '\n';
}

export된 이름만 가져온 번역 단위에서 사용할 수 있다.

export module math;

int add_internal(
    int left,
    int right
)
{
    return left + right;
}

export int add(
    int left,
    int right
)
{
    return add_internal(
        left,
        right
    );
}

외부 사용자는 add()를 호출할 수 있지만 add_internal()은 보이지 않는다.

모듈 인터페이스 단위

모듈의 공개 인터페이스를 제공하는 번역 단위를 모듈 인터페이스 단위라고 한다.

주 모듈 인터페이스 단위는 다음과 같이 시작한다.

export module graphics;

하나의 이름 있는 모듈에는 주 모듈 인터페이스 단위가 하나 존재한다.

// graphics.cppm

export module graphics;

export namespace graphics {

class Device {
public:
    Device();

    void begin_frame();
    void end_frame();
};

}

사용자는 인터페이스가 내보낸 선언을 가져온다.

import graphics;

int main()
{
    graphics::Device device;

    device.begin_frame();
    device.end_frame();
}

개별 선언 내보내기

export module math;

export int add(
    int left,
    int right
);

export double average(
    double left,
    double right
);

내보내기 블록

export module math;

export {

int add(
    int left,
    int right
);

int subtract(
    int left,
    int right
);

class Calculator {
public:
    int multiply(
        int left,
        int right
    ) const;
};

}

내보내기 선언은 모듈 인터페이스 단위의 이름공간 범위에서 사용된다.[107]

이름공간 내보내기

export module geometry;

export namespace geometry {

struct Vector2 {
    float x;
    float y;
};

float length(
    const Vector2& vector
);

}

이름공간 자체가 모듈은 아니지만, 이름공간 블록의 선언들을 한꺼번에 내보낼 수 있다.

모듈 구현 단위

모듈 구현 단위는 같은 이름 있는 모듈에 속하지만 공개 인터페이스를 내보내지 않는 번역 단위다.

// graphics.cpp

module graphics;

namespace graphics {

Device::Device()
{
    initialize_native_device();
}

void Device::begin_frame()
{
    begin_native_frame();
}

void Device::end_frame()
{
    end_native_frame();
}

}

module graphics;는 이 번역 단위가 graphics 모듈에 속함을 나타낸다.

모듈 인터페이스의 선언은 같은 모듈 구현 단위에서 사용할 수 있다.

graphics.cppm
├── 공개 클래스와 함수 선언
└── 모듈 인터페이스 정보 생성

graphics.cpp
├── `module graphics;`
├── 공개 함수 정의
└── 비공개 구현

모듈 구현 단위는 사용자가 직접 가져오는 별도의 공개 모듈이 아니다.

모듈 파티션

큰 모듈은 여러 파티션으로 나눌 수 있다. 파티션 이름은 모듈 이름 뒤에 :를 붙여 표현한다.

// graphics-device.cppm

export module graphics:device;

export namespace graphics {

class Device {
public:
    void submit();
};

}

다른 파티션을 만들 수 있다.

// graphics-command.cppm

export module graphics:command;

export namespace graphics {

class CommandList {
public:
    void draw();
};

}

주 모듈 인터페이스가 파티션을 가져와 다시 내보낸다.

// graphics.cppm

export module graphics;

export import :device;
export import :command;

사용자는 주 모듈만 가져온다.

import graphics;

graphics::Device device;
graphics::CommandList commands;

표준은 모듈 인터페이스 파티션들이 주 모듈 인터페이스를 통해 직접 또는 간접적으로 내보내지도록 요구한다.[108]

내부 파티션

외부에 내보내지 않는 구현 파티션도 만들 수 있다.

// graphics-backend.cppm

module graphics:backend;

namespace graphics::detail {

class Backend {
public:
    void initialize();
};

}

같은 모듈의 다른 단위에서 가져온다.

// graphics.cpp

module graphics;

import :backend;

namespace graphics {

void Device::initialize()
{
    detail::Backend backend;

    backend.initialize();
}

}

파티션은 같은 모듈 내부에서만 파티션 이름으로 가져올 수 있다.

graphics 모듈의 파티션 예시
  • graphics
    • 주 인터페이스
      • graphics.cppm
    • 공개 인터페이스 파티션
      • graphics:device
      • graphics:command
    • 내부 파티션
      • graphics:backend
    • 구현 단위
      • graphics-device.cpp
      • graphics-command.cpp

다시 내보내기

모듈 인터페이스는 다른 모듈을 가져와 자신의 사용자에게 다시 내보낼 수 있다.

// engine.cppm

export module engine;

export import graphics;
export import audio;

export namespace engine {

class Application {
};

}

import engine;을 한 사용자는 graphicsaudio가 다시 내보낸 이름도 사용할 수 있다.

import engine;

graphics::Device device;
audio::Mixer mixer;
engine::Application application;

일반 import는 현재 모듈 구현에서만 의존성을 사용하고 사용자에게 전파하지 않는다.

export module engine;

import internal_scheduler;

export class Application {
};

internal_scheduler가 내보낸 이름은 engine 사용자가 자동으로 사용할 수 없다.

전역 모듈 조각

기존 헤더나 매크로 기반 구성과 모듈을 함께 사용할 때 전역 모듈 조각을 사용할 수 있다.

module;

#include <cstdint>
#include <vulkan/vulkan.h>

#define ENGINE_INTERNAL_OPTION 1

export module graphics;

export namespace graphics {

class Device {
    VkDevice handle_ =
        VK_NULL_HANDLE;
};

}

첫 번째 module;부터 모듈 선언 전까지가 전역 모듈 조각이다.

전역 모듈 조각에 포함된 기존 헤더 선언은 이름 있는 모듈의 소유가 되지 않는다. 전처리 지시문을 모듈 선언 전에 처리해야 할 때 사용한다.[109]

전역 모듈 조각
`module;`
`#include ...`
`#define ...`

↓

모듈 선언
`export module graphics;`

↓

모듈 영역
공개·비공개 선언

전역 모듈 조각은 기존 헤더와의 호환을 위한 경계로 사용되며, 모듈 내부 구현 전체를 다시 헤더 중심 구조로 만들기 위한 일반 영역은 아니다.

비공개 모듈 조각

주 모듈 인터페이스 파일 안에서 공개 인터페이스 뒤에 구현을 배치하고 외부 모듈 단위에서 접근하지 못하게 하려면 비공개 모듈 조각을 사용할 수 있다.

export module calculator;

export class Calculator {
public:
    int add(
        int left,
        int right
    ) const;
};

module :private;

int Calculator::add(
    int left,
    int right
) const
{
    return left + right;
}

module :private; 뒤의 선언은 해당 번역 단위의 비공개 모듈 조각에 속한다.

비공개 모듈 조각을 가진 모듈은 모든 모듈 구현을 하나의 인터페이스 번역 단위에 둘 수 있지만, 큰 모듈에서는 별도 구현 단위가 증분 빌드와 소스 구성에 더 적합할 수 있다.

헤더 단위

헤더 단위는 기존 헤더를 모듈과 유사한 방식으로 가져올 수 있게 한다.

import <vector>;
import <string>;

int main()
{
    std::vector<std::string>
        values;
}

사용자 헤더도 구현체가 가져올 수 있는 헤더로 구성하면 헤더 단위로 사용할 수 있다.

import "legacy_library.hpp";

헤더 단위는 이름 있는 모듈과 같지 않다.

  • 기존 헤더를 기반으로 한다.
  • 헤더의 선언 구조를 그대로 사용한다.
  • 모듈 이름을 새로 정의하지 않는다.
  • 빌드 시스템이 해당 헤더를 가져올 수 있는 단위로 미리 번역해야 할 수 있다.
  • 매크로 전달 규칙이 일반 #include와 다르다.

헤더 단위에서 정의된 매크로가 가져온 번역 단위에 일반 헤더 포함과 같은 방식으로 전달된다고 가정해서는 안 된다. 모듈 가져오기는 선언과 정의 공유를 중심으로 하며 전처리 상태 공유와는 다르다.

C++20의 헤더 단위는 병합된 모듈 설계의 일부로 추가되었다.[110]

#includeimport

특성#includeimport
처리 계층전처리기C++ 언어
기본 동작파일 토큰 삽입모듈 인터페이스 가져오기
반복 분석번역 단위마다 발생 가능번역된 인터페이스 재사용 가능
매크로 영향포함 전후 전처리 상태에 영향이름 있는 모듈은 매크로 상태와 분리
공개 이름포함된 선언 전체내보낸 선언
순서 민감성매크로와 포함 순서에 민감의존성이 더 명시적
순환 구조포함 보호에 의존모듈 의존 그래프 규칙에 의존
빌드 요구파일 포함 경로모듈 의존 순서와 인터페이스 산출물

모듈은 선언의 가시성과 소유권을 명시하지만, 프로그램의 모든 의존성을 자동으로 좋은 구조로 만드는 것은 아니다. 지나치게 큰 모듈, 순환적인 모듈 관계와 불안정한 공개 인터페이스는 여전히 빌드와 설계 문제를 만들 수 있다.

매크로와 모듈

이름 있는 모듈은 가져오는 쪽의 매크로 상태에 따라 인터페이스 선언이 달라지지 않도록 설계된다.

// importer.cpp

#define FEATURE_LEVEL 3

import library;

FEATURE_LEVEL은 일반적으로 이미 번역된 library 모듈 인터페이스의 선언을 다시 조건부로 변경하지 않는다.

반면 헤더 포함은 매크로 상태에 영향을 받는다.

#define FEATURE_LEVEL 3

#include "library.hpp"

이 차이는 모듈 인터페이스를 번역 단위마다 일관되게 유지하는 데 도움이 된다.

모듈 내부 구현에서 전처리 조건이 필요하다면 모듈을 빌드할 때 적용되는 컴파일 구성으로 처리할 수 있다.

export module renderer;

#if defined(RENDERER_ENABLE_VALIDATION)
export constexpr bool
    validation_enabled = true;
#else
export constexpr bool
    validation_enabled = false;
#endif

같은 모듈 이름을 서로 호환되지 않는 매크로 구성으로 중복 빌드하고 한 프로그램에 혼합하면 일관성 문제가 생길 수 있다. 빌드 시스템은 모듈 구성과 산출물을 명확히 구분해야 한다.

모듈과 템플릿

템플릿 정의를 모듈 인터페이스에서 내보낼 수 있다.

export module containers;

export template<class T>
class Box {
    T value_;

public:
    explicit Box(T value)
        : value_(
              std::move(value)
          )
    {
    }

    const T& value() const noexcept
    {
        return value_;
    }
};

사용자는 템플릿 본문을 텍스트로 포함하지 않고 가져온 인터페이스를 통해 인스턴스화할 수 있다.

import containers;

Box<int> integer_box(42);
Box<std::string>
    string_box("C++");

모듈은 템플릿 구현을 암호화하거나 사용자로부터 완전히 숨기는 바이너리 배포 기술은 아니다. 템플릿 인스턴스화에 필요한 의미 정보가 컴파일러가 사용하는 모듈 인터페이스 산출물에 포함되어야 한다.

템플릿의 공개 구현 변경은 이를 사용하는 번역 단위의 재컴파일을 요구할 수 있다.

모듈과 단일 정의 규칙

모듈은 이름의 소유를 더 명확하게 만들지만 ODR을 제거하지 않는다.

// first.cppm
export module first;

export int value = 10;
// second.cppm
export module second;

export int value = 20;

value는 서로 다른 모듈이 소유하더라도 같은 전역 이름공간에 가져와질 때 이름 충돌과 사용 모호성이 생길 수 있다.

import first;
import second;

// int result = value;

공개 이름을 프로젝트 이름공간에 배치할 수 있다.

export module first;

export namespace first {

inline int value = 10;

}

같은 이름 있는 모듈의 단위들은 하나의 논리적 모듈을 구성하므로 내부 선언과 정의 역시 표준의 모듈 및 ODR 규칙을 따라야 한다.

번역 단위 지역 개체

C++20은 번역 단위 내부에서만 의미를 갖는 개체가 템플릿과 모듈 인터페이스를 통해 외부로 노출되는 문제를 다루기 위해 번역 단위 지역 개체 규칙을 도입했다.[111]

namespace {

struct InternalType {
};

}

이 타입을 내보내는 인터페이스의 일부에 직접 사용하면 외부 번역 단위가 동일 개체를 정상적으로 참조할 수 없다.

export module library;

namespace {

struct InternalType {
};

}

// export InternalType create();

공개 인터페이스에는 외부에서 의미가 유지되는 타입만 사용해야 한다.

표준 라이브러리 모듈

C++23은 표준 라이브러리를 가져오기 위한 이름 있는 모듈 stdstd.compat를 규정한다.

import std;

int main()
{
    std::vector<int> values {
        3,
        1,
        2
    };

    std::ranges::sort(values);

    std::println(
        "{} {} {}",
        values[0],
        values[1],
        values[2]
    );
}

std 모듈은 가져올 수 있는 C++ 표준 라이브러리 헤더가 제공하는 std 이름공간 선언 등을 내보낸다.[112]

std.compat는 C 호환 전역 이름 제공과 관련된 호환 목적의 표준 모듈이다.

import std.compat;

표준 모듈이 규격에 포함되어 있더라도 실제 컴파일러, 표준 라이브러리와 빌드 시스템의 사용 방법과 지원 완료 상태는 서로 다를 수 있다. 프로젝트는 대상 도구 체인의 문서를 확인해야 한다.

모듈 빌드 순서

헤더 방식에서는 컴파일러가 소스 파일을 전처리하며 포함 파일을 직접 읽는다. 모듈에서는 가져오는 번역 단위보다 모듈 인터페이스가 먼저 번역되어 있어야 할 수 있다.

`graphics.cppm`
↓ 모듈 인터페이스 번역
graphics 모듈 인터페이스 산출물

↓

`engine.cppm`
`import graphics;`
↓ 번역
engine 모듈 인터페이스 산출물

↓

`main.cpp`
`import engine;`
↓ 번역
main 목적 파일

모듈 의존 관계는 컴파일 순서를 형성한다.

graphics
↓
engine
↓
application

빌드 도구는 소스 내용을 분석해 어떤 모듈을 제공하고 어떤 모듈을 요구하는지 알아내야 한다. WG21의 P1689는 C++ 소스 의존성 스캐너와 빌드 도구 사이에서 모듈 제공·요구 정보를 전달하는 형식을 제안했다.[113]

모듈 인터페이스 산출물의 파일 형식과 이름은 표준이 통일하지 않는다.

도구 체계사용되는 용어 또는 산출물 예
ClangPCM
GCCCMI
Microsoft Visual C++IFC
일반적 표현BMI 또는 모듈 인터페이스 산출물

이러한 산출물은 일반적으로 컴파일러 버전과 옵션에 강하게 의존하며, 서로 다른 컴파일러가 공유하는 장기 배포 형식으로 간주할 수 없다.

모듈 의존 순환

모듈 사이의 순환 의존은 번역 순서와 인터페이스 설계를 어렵게 만든다.

module A
↓ imports
module B
↓ imports
module A

공통 타입과 인터페이스를 별도의 하위 모듈로 분리할 수 있다.

core.types
↑       ↑
A       B
export module core.types;

export struct Identifier {
    unsigned long long value;
};
export module A;

import core.types;

export void process_a(
    Identifier id
);
export module B;

import core.types;

export void process_b(
    Identifier id
);

모듈 파티션은 같은 모듈 내부 구성을 나누는 수단이지 임의의 순환 구조를 허용하는 장치는 아니다.

모듈 API와 구현 경계

모듈 인터페이스에 내보내는 선언은 사용자가 의존하는 공개 API가 된다.

export module database;

export namespace database {

struct ConnectionOptions {
    std::string endpoint;
    int timeout_seconds;
};

class Connection {
public:
    static Connection open(
        const ConnectionOptions&
            options
    );

    QueryResult query(
        std::string_view sql
    );
};

}

공개 인터페이스에 구현 전용 타입을 노출하지 않는다.

export module database;

namespace database::detail {

class NativeConnection {
};

}
export namespace database {

class Connection {
    class Impl;

    std::unique_ptr<Impl> impl_;

public:
    Connection(Connection&&) noexcept;
    Connection& operator=(
        Connection&&
    ) noexcept;

    ~Connection();
};

}

모듈을 사용해도 ABI 안정성이 자동으로 보장되지는 않는다. 공개 클래스의 크기, 가상 함수 테이블, 인라인 함수와 템플릿은 여전히 바이너리 호환성에 영향을 준다.

정적 라이브러리

정적 라이브러리는 여러 목적 파일을 하나의 아카이브로 묶고, 최종 실행 파일을 링크할 때 필요한 코드가 포함되게 한다.

Renderer.o
Audio.o
Physics.o
↓
libengine.a 또는 engine.lib
↓
애플리케이션 링크
↓
실행 파일

정적 라이브러리 코드는 일반적으로 최종 실행 파일에 포함된다.

장점은 다음과 같다.

  • 배포할 별도 공유 라이브러리가 줄어들 수 있다.
  • 실행 파일이 특정 라이브러리 버전을 직접 포함한다.
  • 로딩 시 외부 바이너리 검색 문제가 적다.

고려할 점은 다음과 같다.

  • 여러 실행 파일이 같은 코드를 중복 포함할 수 있다.
  • 라이브러리 갱신 시 실행 파일을 다시 링크해야 한다.
  • 링크 순서와 심볼 제거 규칙이 영향을 줄 수 있다.
  • 라이선스와 배포 정책을 확인해야 한다.

공유 라이브러리

공유 라이브러리는 실행 시 별도의 바이너리로 로드된다.

플랫폼일반적인 형식
Windows.dll
Linux 및 Unix 계열.so
macOS.dylib, 프레임워크

공개 심볼의 내보내기와 가져오기를 제어할 수 있다.

#if defined(_WIN32)

#if defined(ENGINE_BUILD)
#define ENGINE_API \
    __declspec(dllexport)
#else
#define ENGINE_API \
    __declspec(dllimport)
#endif

#else

#define ENGINE_API \
    __attribute__((visibility("default")))

#endif
class ENGINE_API Renderer {
public:
    void render();
};

이 매크로는 컴파일러와 플랫폼별 확장이므로 표준 C++만의 기능은 아니다.

심볼 가시성

공개할 필요가 없는 심볼을 숨기면 다음 효과를 얻을 수 있다.

  • 동적 심볼 테이블 크기 감소
  • 내부 구현 이름 충돌 감소
  • 최적화 가능성 증가
  • 비공개 ABI 노출 감소

공개 API만 명시적으로 내보내는 구조를 사용할 수 있다.

ABI

ABI는 별도로 컴파일된 바이너리 구성 요소가 함수 호출, 객체 배치, 이름 장식과 예외 전달을 어떻게 맞추는지를 규정한다.

ABI에 영향을 주는 요소는 다음과 같다.

  • 호출 규약
  • 매개변수와 반환값 전달
  • 구조체와 클래스 배치
  • 정렬과 패딩
  • 가상 함수 테이블
  • RTTI 표현
  • 예외 처리 구현
  • 이름 장식
  • 표준 라이브러리 타입 구현
  • 컴파일러 옵션

C++ 표준은 모든 플랫폼에 공통된 하나의 바이너리 ABI를 규정하지 않는다.

class Plugin {
public:
    virtual void run() = 0;
    virtual ~Plugin() = default;
};

이 인터페이스를 DLL이나 공유 라이브러리 경계에 직접 노출하려면 양쪽이 호환 가능한 컴파일러, 런타임과 ABI 설정을 사용해야 한다.

더 안정적인 경계로 C ABI와 불투명 핸들을 사용할 수 있다.

extern "C" {

struct EngineHandle;

EngineHandle*
engine_create();

void engine_destroy(
    EngineHandle* engine
);

void engine_run(
    EngineHandle* engine
);

}

C ABI도 플랫폼마다 완전히 동일하지는 않지만 C++ 클래스와 템플릿 ABI보다 좁은 경계를 제공한다.

extern "C"

extern "C"는 선언에 C 언어 연결을 지정한다.

extern "C" {

int library_initialize();

void library_shutdown();

}

이 기능은 함수 구현을 C 문법으로 바꾸지 않는다. 함수 본문은 여전히 C++로 작성할 수 있다.

extern "C"
int library_initialize()
{
    try {
        initialize_cpp_runtime();

        return 0;
    }
    catch (...) {
        return -1;
    }
}

예외와 C++ 객체를 C ABI 경계 밖으로 직접 전달하지 않고 오류 코드와 불투명 핸들로 변환할 수 있다.

함수 오버로딩은 C 연결 이름으로 직접 표현할 수 없다.

// extern "C" int convert(int);
// extern "C" double convert(double);

플러그인 구성

플러그인 시스템은 실행 중 공유 라이브러리를 불러오고 정해진 진입 함수를 찾을 수 있다.

extern "C" {

struct PluginApi {
    unsigned int version;

    void (*initialize)();
    void (*update)(double);
    void (*shutdown)();
};

const PluginApi*
plugin_get_api(
    unsigned int host_version
);

}

호스트는 운영체제 API로 심볼을 조회한다.

using GetPluginApi =
    const PluginApi* (*)(
        unsigned int
    );

GetPluginApi get_api =
    reinterpret_cast<GetPluginApi>(
        load_symbol(
            library,
            "plugin_get_api"
        )
    );

플러그인 경계에서는 다음 요소를 명시해야 한다.

  • API 버전
  • 구조체 크기
  • 메모리 할당과 해제 주체
  • 스레드 사용 규칙
  • 오류 전달
  • 호출 규약
  • 객체 수명
  • 라이브러리 해제 시점
  • 컴파일러 및 ABI 호환 범위

모듈은 컴파일 시 프로그램 구성을 위한 기능이며, 실행 중 동적 플러그인 로딩을 직접 제공하지 않는다.

실행 파일 진입점

호스팅 환경의 일반적인 C++ 프로그램은 main 함수에서 시작한다.

int main()
{
    return 0;
}

명령줄 인자를 받을 수 있다.

int main(
    int argument_count,
    char* arguments[]
)
{
    for (int index = 0;
         index < argument_count;
         ++index) {
        process_argument(
            arguments[index]
        );
    }

    return 0;
}

실제 운영체제와 프레임워크는 다른 네이티브 진입점을 요구할 수 있다.

  • Windows GUI의 WinMain 또는 wWinMain
  • macOS와 Cocoa 애플리케이션 초기화
  • Android NativeActivity
  • 게임 콘솔 SDK 진입점
  • 임베디드 런타임 초기화 함수

플랫폼 계층이나 엔트리 매크로가 네이티브 진입점을 숨기고 사용자 애플리케이션 객체를 호출할 수 있다.

#include <engine/Engine.hpp>

class Game :
    public engine::Application {
public:
    void start() override
    {
    }

    void update(
        double delta_time
    ) override
    {
    }
};

ENGINE_GAME(
    "Example",
    Game
)

매크로 내부에서 대상 플랫폼에 맞는 실제 진입 함수를 정의할 수 있다.

초기화와 종료 순서

전역 및 정적 객체는 main() 시작 전후와 프로그램 종료 과정에서 초기화·파괴될 수 있다.

Logger global_logger;
Configuration global_configuration;

서로 다른 번역 단위의 동적 초기화 순서에 의존하면 초기화 순서 문제가 발생할 수 있다.

// logger.cpp

Logger global_logger(
    create_log_file()
);
// configuration.cpp

Configuration global_configuration(
    global_logger
);

global_logger가 먼저 초기화된다는 보장이 필요한 구조가 될 수 있다.

함수 지역 정적 객체로 필요한 시점에 초기화할 수 있다.

Logger& logger()
{
    static Logger instance(
        create_log_file()
    );

    return instance;
}

또는 최상위 애플리케이션 객체가 하위 시스템의 생성 순서를 직접 소유하게 한다.

class Application {
    Logger logger_;
    Configuration configuration_;
    Window window_;
    Renderer renderer_;

public:
    Application()
        : logger_(
              create_log_file()
          ),
          configuration_(
              load_configuration(
                  logger_
              )
          ),
          window_(
              configuration_
          ),
          renderer_(
              window_,
              configuration_
          )
    {
    }
};

멤버는 선언 순서대로 생성되고 역순으로 파괴된다.

빌드 시스템

C++ 표준은 소스 파일을 어떤 명령으로 컴파일하고 라이브러리를 어떻게 찾는지 규정하지 않는다. 빌드 시스템이 소스 파일, 컴파일 옵션, 의존 라이브러리, 생성 코드와 설치 구성을 관리한다.

일반적인 빌드 시스템은 다음과 같다.

  • CMake
  • Meson
  • Bazel
  • Ninja
  • GNU Make
  • MSBuild
  • Xcode Build System
  • Premake
  • Buck2
  • xmake

CMake 예시는 다음과 같다.

cmake_minimum_required(
    VERSION 3.28
)

project(
    TechPediaExample
    LANGUAGES CXX
)

add_library(
    calculator
    STATIC
    src/calculator.cpp
)

target_include_directories(
    calculator
    PUBLIC
        include
)

target_compile_features(
    calculator
    PUBLIC
        cxx_std_23
)

add_executable(
    application
    src/main.cpp
)

target_link_libraries(
    application
    PRIVATE
        calculator
)

대상 기반 빌드에서는 include 경로, 컴파일 정의와 연결 라이브러리가 소비 대상에 전파될 수 있다.

target_compile_definitions(
    calculator
    PRIVATE
        CALCULATOR_BUILD
)

target_link_libraries(
    calculator
    PRIVATE
        Threads::Threads
)

모듈 빌드

모듈을 지원하는 최신 빌드 시스템은 모듈 인터페이스 파일을 별도 파일 집합으로 지정할 수 있다.

add_library(
    graphics
)

target_sources(
    graphics
    PUBLIC
        FILE_SET CXX_MODULES
        FILES
            src/graphics.cppm
            src/graphics-device.cppm
            src/graphics-command.cppm
    PRIVATE
        src/graphics.cpp
)

실제 요구 CMake 버전과 컴파일러 지원 범위는 사용 환경에 따라 달라질 수 있다.

모듈 빌드는 단순히 .cppm 확장자를 컴파일 목록에 추가하는 것 이상으로 다음 작업이 필요할 수 있다.

  1. 각 소스가 제공하는 모듈 이름 분석
  2. 각 소스의 import 의존성 분석
  3. 모듈 인터페이스 컴파일 순서 결정
  4. 인터페이스 산출물 경로 전달
  5. 컴파일 옵션과 모듈 캐시 일치
  6. 일반 목적 파일과 최종 링크

컴파일 옵션의 일관성

모듈 인터페이스와 가져오는 번역 단위가 서로 호환되지 않는 옵션으로 컴파일되면 모듈 산출물을 사용할 수 없거나 프로그램 의미가 달라질 수 있다.

영향을 줄 수 있는 옵션은 다음과 같다.

  • C++ 표준 버전
  • 디버그 및 릴리스 런타임
  • 예외 활성화
  • RTTI 활성화
  • 구조체 패킹
  • ABI 버전
  • 표준 라이브러리 구현
  • 전처리 정의
  • 대상 아키텍처
  • 언어 확장 모드
c++ \
    -std=c++23 \
    -O2 \
    -DNDEBUG \
    -c source.cpp

공개 헤더나 모듈 인터페이스의 의미를 바꾸는 옵션은 라이브러리와 모든 소비자 사이에서 일치시켜야 한다.

증분 빌드

증분 빌드는 변경된 파일과 그 의존 대상만 다시 번역한다.

헤더가 변경되면 이를 포함하는 모든 번역 단위가 다시 컴파일될 수 있다.

Common.hpp 변경
├── Renderer.cpp 재컴파일
├── Audio.cpp 재컴파일
├── Physics.cpp 재컴파일
├── Editor.cpp 재컴파일
└── Game.cpp 재컴파일

구현 세부 사항을 소스 파일로 이동하면 공개 헤더가 바뀌지 않는 수정은 해당 구현 번역 단위만 다시 컴파일할 수 있다.

Renderer.cpp 변경
└── Renderer.cpp만 재컴파일
    ↓
라이브러리 또는 실행 파일 다시 링크

모듈도 인터페이스 변경과 구현 변경의 영향을 구분할 수 있다.

모듈 인터페이스 변경
└── 가져오는 번역 단위 재컴파일 가능

모듈 구현 단위만 변경
└── 해당 구현 단위 재컴파일과 재링크

정확한 재빌드 범위는 컴파일러와 빌드 시스템의 모듈 의존성 및 인터페이스 해시 처리 방식에 따라 달라진다.

프로젝트 구조

소규모 라이브러리는 공개 API와 구현을 다음처럼 나눌 수 있다.

project/
├── CMakeLists.txt
├── include/
│   └── project/
│       ├── Application.hpp
│       └── Renderer.hpp
├── src/
│   ├── Application.cpp
│   └── Renderer.cpp
└── tests/
    └── RendererTests.cpp

모듈 기반 구조는 다음처럼 구성할 수 있다.

project/
├── CMakeLists.txt
├── modules/
│   ├── project.cppm
│   ├── project.core.cppm
│   ├── project.graphics.cppm
│   └── project.graphics.backend.cppm
├── src/
│   ├── core.cpp
│   ├── graphics.cpp
│   └── backend.cpp
├── application/
│   └── main.cpp
└── tests/
    └── graphics_tests.cpp

하나의 이름 있는 모듈에 파티션을 사용하는 구조라면 다음처럼 표현할 수 있다.

modules/
├── engine.cppm
├── engine-core.cppm
├── engine-renderer.cppm
└── engine-audio.cppm
// engine.cppm

export module engine;

export import :core;
export import :renderer;
export import :audio;
// engine-core.cppm

export module engine:core;

export namespace engine {

class Application {
};

}

모듈 이름과 디렉터리 이름, 파일 이름을 반드시 동일하게 만들 필요는 없지만 일관된 규칙이 도구와 사용자에게 도움이 된다.

계층화

프로젝트는 상위 계층이 하위 계층에 의존하고 하위 계층은 상위 계층을 모르게 구성할 수 있다.

application
↓
game
↓
engine
├── renderer
├── physics
├── audio
└── platform
    ↓
operating system

공통 기반 모듈이 상위 기능에 의존하면 순환 구조가 생길 수 있다.

core
↓ imports
renderer

renderer
↓ imports
core

공통 타입을 더 작은 기반 모듈로 분리할 수 있다.

foundation
├── identifiers
├── math
└── memory

renderer
└── imports foundation

physics
└── imports foundation

engine
├── imports renderer
├── imports physics
└── imports foundation

계층은 빌드 방향뿐 아니라 런타임 소유권과 API 책임도 반영할 수 있다.

기능 단위 구성

기술 계층만으로 나누지 않고 기능 단위로 구성할 수도 있다.

features/
├── authentication/
│   ├── Authentication.cppm
│   ├── LoginService.cpp
│   └── CredentialStore.cpp
├── documents/
│   ├── Documents.cppm
│   ├── Document.cpp
│   └── DocumentStore.cpp
└── synchronization/
    ├── Synchronization.cppm
    └── SyncService.cpp

기능 단위는 관련 타입과 로직을 가까이 두고, 기능 삭제와 테스트 경계를 명확하게 만들 수 있다.

대형 시스템에서는 기능 단위와 기술 계층을 조합할 수 있다.

공개 API 최소화

헤더나 모듈에서 외부에 공개한 이름은 사용자가 의존할 수 있는 API가 된다.

export module renderer;

export class VulkanDescriptorAllocator {
};

export class VulkanCommandPool {
};

export class VulkanPipelineCache {
};

백엔드 세부 타입까지 공개하면 사용자가 구현 기술에 직접 의존한다.

export module renderer;

export namespace renderer {

class Device;
class Buffer;
class Texture;

std::unique_ptr<Device>
create_device(
    const DeviceDescription&
        description
);

}

구현 타입은 모듈 내부에 둔다.

module renderer;

class VulkanDevice final :
    public renderer::Device {
};

공개 API가 작을수록 다음 변경의 영향이 줄어든다.

  • 구현 교체
  • 헤더와 모듈 인터페이스 재번역
  • ABI 변경
  • 문서와 테스트 유지
  • 사용자 코드 결합

의존성 역전

상위 정책이 하위 구현 타입에 직접 의존하지 않도록 추상 인터페이스를 둘 수 있다.

export module storage.api;

export namespace storage {

class Store {
public:
    virtual void save(
        const Document& document
    ) = 0;

    virtual Document load(
        DocumentId id
    ) = 0;

    virtual ~Store() = default;
};

}

구현 모듈은 인터페이스에 의존한다.

export module storage.sqlite;

import storage.api;

export std::unique_ptr<
    storage::Store
>
create_sqlite_store(
    const Path& path
);

애플리케이션은 생성 위치에서 구체 구현을 선택하고 나머지 코드에는 인터페이스를 전달한다.

import storage.api;
import storage.sqlite;

int main()
{
    auto store =
        create_sqlite_store(
            "documents.db"
        );

    Application application(
        *store
    );

    application.run();
}

모든 타입을 가상 인터페이스로 만들 필요는 없다. 컴파일 시간에 구현이 고정되고 성능이 중요한 내부 알고리즘은 템플릿과 값 합성을 사용할 수 있다.

테스트 구성

테스트는 공개 API를 사용하는 별도 번역 단위 또는 별도 실행 파일로 구성할 수 있다.

import calculator;

#include <cassert>

int main()
{
    assert(
        add(20, 22) == 42
    );
}

내부 구현을 직접 테스트하기 위해 비공개 헤더에 의존하면 리팩터링 비용이 증가할 수 있다. 공개 동작을 중심으로 테스트하고, 복잡한 내부 알고리즘은 별도의 작은 타입이나 내부 모듈로 분리해 직접 테스트할 수 있다.

tests/
├── unit/
│   ├── ParserTests.cpp
│   └── LexerTests.cpp
├── integration/
│   └── CompilerPipelineTests.cpp
└── application/
    └── CommandLineTests.cpp

모듈 파티션은 같은 모듈 내부에서만 접근할 수 있으므로 테스트용 공개 경계를 어떻게 구성할지 프로젝트 정책이 필요하다.

생성 코드

프로토콜, 셰이더, 직렬화 스키마와 리소스 정보를 바탕으로 C++ 소스를 생성할 수 있다.

schema.json
↓ 코드 생성기
GeneratedProtocol.hpp
GeneratedProtocol.cpp
↓ 컴파일
목적 파일

모듈 인터페이스도 생성할 수 있다.

api.schema
↓ 생성기
api.generated.cppm
↓ 모듈 인터페이스 번역

빌드 시스템은 생성 단계가 컴파일보다 먼저 실행되도록 의존성을 설정해야 한다.

생성 파일과 직접 작성 파일을 명확하게 분리할 수 있다.

project/
├── src/
├── include/
├── generated/
│   ├── Protocol.cpp
│   └── Protocol.hpp
└── schemas/
    └── protocol.json

패키지와 외부 의존성

C++ 표준은 라이브러리 패키지 설치와 버전 해결 방식을 규정하지 않는다. 프로젝트는 패키지 관리자 또는 시스템 패키지를 사용할 수 있다.

일반적인 도구는 다음과 같다.

  • Conan
  • vcpkg
  • 시스템 패키지 관리자
  • CPM.cmake
  • FetchContent
  • Bazel 모듈
  • 직접 소스 포함
  • 사내 패키지 저장소

외부 라이브러리를 사용할 때는 다음을 관리해야 한다.

  • 버전 고정
  • 라이선스
  • 컴파일 옵션
  • 런타임 라이브러리
  • 정적 또는 공유 연결
  • ABI 호환성
  • 플랫폼별 패치
  • 재현 가능한 빌드
  • 보안 업데이트

모듈 인터페이스 산출물을 미리 빌드해 일반 바이너리 패키지처럼 장기간 배포하는 것은 컴파일러 종속성 때문에 제한될 수 있다. 소스 모듈 인터페이스와 일반 라이브러리 바이너리를 함께 배포하고 소비자 도구 체계에서 인터페이스를 번역하는 구조가 사용될 수 있다.

모듈 도입 전략

기존 헤더 기반 프로젝트를 한 번에 모두 모듈로 변환할 필요는 없다.

기존 프로젝트의 모듈 전환
  1. 기존 헤더와 소스 구조 유지
  2. 안정된 하위 라이브러리 선택
  3. 공개 인터페이스와 구현 의존성 정리
  4. 주 모듈 인터페이스 작성
  5. 기존 헤더는 호환 계층으로 유지
  6. 내부 구현 단위를 모듈에 편입
  7. 빌드 시스템의 모듈 의존성 지원 확인
  8. 다른 구성 요소로 점진적 확대

기존 헤더 API를 모듈에서 다시 내보내는 임시 호환 계층을 만들 수 있다.

module;

#include "legacy_math.hpp"

export module math;

// legacy_math.hpp의 이름을 단순히 모두 자동 내보내는 것은 아니다.
// 필요한 공개 선언을 모듈 인터페이스로 정리한다.

export namespace math {

using ::LegacyVector;

double length(
    const LegacyVector&
        vector
);

}

또는 헤더 단위로 가져와 다시 내보낼 수 있는 구성이 구현체에서 지원될 수 있다.

export module legacy;

export import "legacy_library.hpp";

헤더의 매크로 중심 API와 조건부 선언이 많으면 먼저 일반 C++ 타입과 함수 중심으로 인터페이스를 정리해야 한다.

모듈 지원과 이식성

C++20 모듈은 표준 언어 기능이지만 실제 도구 체계에서는 다음 부분의 지원이 서로 다른 속도로 발전했다.

  • 모듈 인터페이스 컴파일
  • 파티션
  • 헤더 단위
  • 표준 라이브러리 모듈
  • 의존성 자동 스캔
  • IDE 코드 분석
  • 디버거 정보
  • 빌드 캐시
  • 분산 빌드
  • 여러 컴파일러 혼합
  • 패키지 관리자 연동

컴파일러 지원표에서도 모듈 기능이 구현체별로 부분 지원으로 표시될 수 있으므로, 대상 버전과 필요한 세부 기능을 직접 확인해야 한다.[114]

프로젝트가 여러 컴파일러와 오래된 플랫폼을 지원한다면 헤더 API를 유지하면서 특정 빌드에서만 모듈 인터페이스를 제공할 수 있다.

공통 구현 라이브러리
├── 전통적 헤더 API
└── C++ 모듈 API

사용 환경
├── 모듈 지원 도구 → `import`
└── 기존 도구 → `#include`

두 인터페이스가 서로 다른 API로 분기하지 않도록 하나의 내부 선언 원천에서 생성하거나 테스트할 수 있다.

프로그램 구성의 일반 원칙

C++ 프로젝트 구성은 다음 원칙을 기준으로 설계할 수 있다.

  1. 공개 인터페이스와 비공개 구현을 구분한다.
  2. 헤더는 가능하면 자기 완결적으로 작성한다.
  3. 공개 헤더가 불필요한 구현 헤더를 포함하지 않게 한다.
  4. 구현 파일 내부 이름은 익명 이름공간이나 내부 모듈 영역에 둔다.
  5. 비인라인 외부 연결 정의는 하나의 번역 단위에 둔다.
  6. 템플릿과 인라인 함수 정의는 필요한 사용자가 볼 수 있는 위치에 둔다.
  7. 전역 가변 상태보다 명시적인 상위 소유 객체를 사용한다.
  8. 번역 단위 사이의 순환 의존성을 줄인다.
  9. 공통 타입을 독립적인 하위 계층에 배치한다.
  10. 모듈 이름과 이름공간의 역할을 구분한다.
  11. 외부에 필요한 선언만 export한다.
  12. 모듈 파티션은 하나의 큰 모듈 내부 구성을 나누는 데 사용한다.
  13. 다른 모듈을 사용자에게 공개할 필요가 있을 때만 다시 내보낸다.
  14. 기존 헤더와 매크로가 필요한 부분은 전역 모듈 조각이나 헤더 단위로 경계를 만든다.
  15. 모듈 인터페이스 산출물을 장기 호환 바이너리 형식으로 가정하지 않는다.
  16. 빌드 시스템이 모듈 의존성과 컴파일 순서를 분석할 수 있어야 한다.
  17. 공유 라이브러리 경계에서는 ABI와 메모리 소유권을 명시한다.
  18. 플러그인 경계에는 버전이 있는 C ABI 또는 제한된 안정 ABI를 고려한다.
  19. 변경 빈도가 높은 구현 세부 사항을 공개 인터페이스에서 제거한다.
  20. 프로젝트 구조는 소스 디렉터리 모양뿐 아니라 의존 방향을 반영해야 한다.

종합 예제

다음 예제는 하나의 애플리케이션을 여러 모듈과 구현 단위로 구성한다.

application/
├── CMakeLists.txt
├── modules/
│   ├── foundation.cppm
│   ├── renderer.cppm
│   ├── renderer-types.cppm
│   ├── game.cppm
│   └── engine.cppm
├── src/
│   ├── renderer.cpp
│   ├── game.cpp
│   └── engine.cpp
└── app/
    └── main.cpp

기반 타입 모듈은 다른 상위 시스템을 가져오지 않는다.

// foundation.cppm

export module foundation;

import std;

export namespace foundation {

struct Identifier {
    std::uint64_t value = 0;

    auto operator<=>(
        const Identifier&
    ) const = default;
};

struct Vector2 {
    float x = 0.0F;
    float y = 0.0F;
};

}

렌더링 타입 파티션은 공개 값 타입을 제공한다.

// renderer-types.cppm

export module renderer:types;

import foundation;

export namespace renderer {

using ResourceId =
    foundation::Identifier;

struct TextureDescription {
    int width;
    int height;
};

struct DrawCommand {
    ResourceId texture;
    foundation::Vector2 position;
};

}

주 렌더러 인터페이스는 파티션을 다시 내보낸다.

// renderer.cppm

export module renderer;

export import :types;

import std;

export namespace renderer {

class Device {
    class Impl;

    std::unique_ptr<Impl> impl_;

public:
    Device();
    ~Device();

    Device(Device&&) noexcept;
    Device& operator=(
        Device&&
    ) noexcept;

    ResourceId create_texture(
        const TextureDescription&
            description
    );

    void submit(
        std::span<
            const DrawCommand
        > commands
    );
};

}

렌더러 구현은 비공개 네이티브 API를 사용한다.

// renderer.cpp

module;

#include <vulkan/vulkan.h>

module renderer;

import std;

class renderer::Device::Impl {
public:
    VkDevice device =
        VK_NULL_HANDLE;

    std::unordered_map<
        ResourceId,
        VkImage
    > images;
};

renderer::Device::Device()
    : impl_(
          std::make_unique<Impl>()
      )
{
}

renderer::Device::~Device() =
    default;

게임 모듈은 렌더러 공개 인터페이스에만 의존한다.

// game.cppm

export module game;

import foundation;
import renderer;
import std;

export namespace game {

class World {
    std::vector<
        renderer::DrawCommand
    > commands_;

public:
    void update(
        double delta_time
    );

    std::span<
        const renderer::DrawCommand
    > draw_commands() const noexcept
    {
        return commands_;
    }
};

}

엔진 모듈은 하위 시스템을 조합한다.

// engine.cppm

export module engine;

import game;
import renderer;

export namespace engine {

class Application {
    renderer::Device renderer_;
    game::World world_;

public:
    void run();
};

}
// engine.cpp

module engine;

import std;

void engine::Application::run()
{
    using Clock =
        std::chrono::steady_clock;

    auto previous =
        Clock::now();

    while (!should_exit()) {
        auto current =
            Clock::now();

        double delta_time =
            std::chrono::duration<
                double
            >(
                current - previous
            ).count();

        previous = current;

        world_.update(delta_time);

        renderer_.submit(
            world_.draw_commands()
        );
    }
}

애플리케이션은 최상위 모듈만 가져온다.

// main.cpp

import engine;

int main()
{
    engine::Application
        application;

    application.run();

    return 0;
}

모듈 의존 관계는 다음과 같다.

모듈 의존 관계
  1. foundation
  2. renderer:types
  3. renderer
  4. game
  5. engine
  6. application

이 구조에서는 하위 계층이 상위 애플리케이션을 알지 못한다. renderer의 Vulkan 구현은 전역 모듈 조각과 구현 단위 안에 숨겨지며, game은 네이티브 그래픽스 API가 아니라 렌더러가 내보낸 값 타입과 인터페이스만 사용한다.

C++ 프로그램 구성은 단순히 헤더와 소스 파일을 서로 나누는 작업에 그치지 않는다. 어떤 이름을 공개하고, 어느 번역 단위가 정의를 소유하며, 변경이 어떤 의존 대상의 재컴파일과 ABI에 영향을 주는지를 설계하는 작업이다.

헤더 기반 구성은 긴 역사와 넓은 도구 지원을 가지며 C 및 기존 C++ 라이브러리와 직접 연결된다. 모듈은 공개 선언과 내부 구현의 경계를 언어 차원에서 표현하고, 매크로 환경에 덜 의존하는 인터페이스와 명시적인 의존 그래프를 제공한다. 실제 프로젝트에서는 대상 컴파일러와 빌드 시스템, 외부 라이브러리의 지원 상태를 고려해 헤더, 헤더 단위와 이름 있는 모듈을 함께 사용할 수 있다.

C와의 관계

C++는 C (프로그래밍 언어)를 기반으로 발전한 언어다. 초기 C++는 C의 실행 모델과 표현식 문법, 함수, 포인터, 배열, 구조체, 전처리기와 별도 컴파일 모델을 유지하면서 클래스, 생성자와 소멸자, 함수 오버로딩, 참조, 가상 함수와 타입 검사를 추가하는 방향으로 설계되었다.

이러한 역사 때문에 C와 C++는 많은 문법과 구현 기술을 공유하며, 하나의 시스템에서 두 언어로 작성된 코드를 함께 연결하는 경우도 많다. 그러나 현대의 C와 C++는 각각 독립된 ISO 표준과 표준화 위원회를 가진 별개의 프로그래밍 언어다. C는 ISO/IEC JTC 1/SC 22/WG14가, C++는 ISO/IEC JTC 1/SC 22/WG21이 표준화한다.[115][116]

C++ 표준은 호환성 부속서에서 C와 C++의 차이를 별도로 설명한다. 이는 두 언어가 높은 수준의 공통 기반을 가지면서도 모든 C 프로그램이 C++ 프로그램으로 그대로 인정되는 것은 아님을 보여준다.[117]

C와 C++의 관계
    • C++가 계승한 기반
      • 기본 표현식과 연산자
      • 함수와 블록
      • 포인터와 배열
      • 구조체와 공용체
      • 전처리기
      • 별도 컴파일과 링킹
    • C와 C++가 공유하는 활용 영역
      • 운영체제
      • 임베디드 시스템
      • 하드웨어 제어
      • 네이티브 라이브러리
      • 성능 중심 소프트웨어
      • 더 강한 정적 타입 검사
      • 클래스와 객체 수명
      • 함수 및 연산자 오버로딩
      • 템플릿과 제네릭 프로그래밍
      • 예외 처리
      • 이름공간과 모듈
      • 독립된 표준 라이브러리

역사적 계승

C++의 전신인 C with Classes는 C 컴파일 환경과 기존 C 코드를 활용하면서 사용자 정의 타입과 클래스 기반 추상화를 추가하려는 목적으로 개발되었다. 비야네 스트롭스트룹은 C의 효율성과 시스템 프로그래밍 능력을 유지하면서 더 높은 수준의 추상화 기능을 제공하는 것을 중요한 설계 방향으로 삼았다.

초기의 설명에서는 C++를 일부 예외를 제외한 C의 상위집합으로 표현하기도 했다. 이는 당시의 C 프로그램과 도구를 최대한 활용하려 했다는 역사적 방향을 설명하는 데에는 유용하지만, 현대 표준의 C와 C++ 관계를 엄밀하게 설명하는 표현으로는 부족하다.[118][119]

C++가 C와 높은 호환성을 유지한 이유에는 다음과 같은 배경이 있다.

  • 이미 널리 사용되던 C 코드와 라이브러리를 재사용할 수 있었다.
  • 기존 C 컴파일러와 링커, 디버거와 운영체제 환경을 활용할 수 있었다.
  • 시스템 프로그래머가 새로운 실행 환경으로 완전히 이동하지 않아도 되었다.
  • C로 작성된 운영체제 API와 장치 인터페이스를 직접 호출할 수 있었다.
  • 저수준 메모리 표현과 기계 자원에 대한 접근 방식을 유지할 수 있었다.

초기 C++ 구현인 Cfront도 C++ 소스를 C 코드로 변환한 뒤 기존 C 컴파일러로 처리하는 구조를 사용했다. 다만 현대 C++의 의미가 C로 단순 변환될 수 있다는 뜻은 아니다. 현대 컴파일러는 C++의 객체 모델, 예외 처리, 템플릿, 런타임 타입 정보와 메모리 모델을 직접 구현한다.

C에서 C++로 이어진 설계 방향
  1. C의 실행 효율과 시스템 접근 능력 유지
  2. 기존 C 소스와 라이브러리 활용
  3. 사용자 정의 타입과 클래스 도입
  4. 객체 수명과 추상화 기능 확장
  5. 템플릿과 표준 라이브러리 발전
  6. C와 공통 기반을 가진 독립 언어로 표준화

공통 문법

C와 C++는 기본적인 프로그램 형태에서 많은 문법을 공유한다.

#include <stdio.h>

int add(int left, int right)
{
    return left + right;
}

int main(void)
{
    int result = add(20, 22);

    printf("%d\n", result);

    return 0;
}

이 코드는 일반적인 C와 C++ 구현에서 모두 번역할 수 있는 형태다.

두 언어가 공통으로 제공하는 대표적인 문법은 다음과 같다.

  • 정수형과 부동소수점형
  • 포인터와 배열
  • 함수 선언과 호출
  • if, switch, for, whiledo while
  • struct, unionenum
  • 산술·비교·논리·비트 연산자
  • 전처리 지시문
  • 별도 번역 단위와 링킹
  • sizeof
  • 명시적인 메모리 주소 접근
struct Position {
    float x;
    float y;
};

float length_squared(
    struct Position position
)
{
    return position.x * position.x +
           position.y * position.y;
}

이와 같은 구조체 기반 데이터와 자유 함수의 조합은 두 언어에서 모두 사용할 수 있다.

C는 C++의 완전한 부분집합이 아님

많은 C 소스가 C++로도 번역될 수 있지만, 표준 C 전체가 표준 C++의 엄밀한 부분집합은 아니다. C에서는 유효하지만 C++에서는 오류가 되거나 다른 의미를 갖는 코드가 존재하며, 반대로 C++의 대부분 기능은 C에 존재하지 않는다.

C++ 표준은 부속서 C에서 C와의 차이를 별도로 열거한다.[120]

void*의 암시적 변환

C에서는 void*를 다른 객체 포인터 타입으로 암시적으로 변환할 수 있다.

/* C */

#include <stdlib.h>

int* values =
    malloc(
        10 * sizeof(int)
    );

C++에서는 void*에서 다른 객체 포인터 타입으로의 암시적 변환을 허용하지 않는다.

#include <cstdlib>

// int* values =
//     std::malloc(
//         10 * sizeof(int)
//     );

명시적 변환을 작성할 수는 있다.

int* values =
    static_cast<int*>(
        std::malloc(
            10 * sizeof(int)
        )
    );

std::free(values);

일반적인 C++ 코드에서는 직접 malloc()을 사용하는 대신 컨테이너나 소유 객체를 사용한다.

#include <vector>

std::vector<int> values(10);

문자열 리터럴의 타입

C에서 일반 문자열 리터럴은 수정할 수 없는 저장 영역에 놓일 수 있지만 타입 체계상 char 배열로 취급된다. C++에서 일반 문자열 리터럴은 const char 배열이다.

/* C */

char* text = "C";

C++에서는 const 포인터가 필요하다.

const char* text = "C++";

문자열 리터럴을 수정하려는 동작은 두 언어 모두에서 올바른 일반 사용이 아니다.

char text[] = "C++";

text[0] = 'c';

수정 가능한 문자 배열이 필요하면 배열 객체를 별도로 만들어야 한다.

함수 선언 f()

C에서 매개변수 목록이 비어 있는 오래된 형태의 함수 선언은 매개변수 정보가 지정되지 않았다는 의미로 사용돼 왔다.

/* C */

int calculate();

C++에서 같은 선언은 인자를 받지 않는 함수를 뜻한다.

int calculate();

C에서 인자를 받지 않는 함수임을 명확히 나타내는 전통적인 형식은 void 매개변수를 사용한다.

/* C */

int calculate(void);

C++에서도 이 형식은 허용되지만 일반적으로 빈 매개변수 목록을 사용한다.

int calculate();

문자 리터럴

C에서 일반 문자 리터럴의 타입은 int다.

/* C */

sizeof('A') == sizeof(int)

C++에서 일반적인 단일 문자 리터럴의 타입은 char다.

static_assert(
    sizeof('A') == sizeof(char)
);

따라서 문자 리터럴과 오버로드, sizeof, 템플릿 인자 추론이 결합되는 코드에서는 결과가 달라질 수 있다.

C++ 키워드

C에서는 일반 식별자로 사용할 수 있지만 C++에서는 키워드인 이름이 있다.

/* C */

int new = 10;
int class = 20;

C++에서 newclass는 언어 키워드이므로 같은 이름을 식별자로 사용할 수 없다.

// int new = 10;
// int class = 20;

C와 C++ 양쪽에서 사용하는 헤더와 API에서는 두 언어 모두의 예약어를 피해야 한다.

열거형 변환

C의 열거형과 정수형 사이에는 C++보다 넓은 암시적 사용이 허용되는 경우가 있다.

/* C */

enum State {
    STATE_STOPPED,
    STATE_RUNNING
};

enum State state = 1;

C++에서는 정수에서 열거형으로의 암시적 변환을 일반적으로 허용하지 않는다.

enum State {
    StateStopped,
    StateRunning
};

// State state = 1;

State state =
    static_cast<State>(1);

현대 C++에서는 범위가 있는 열거형을 사용할 수 있다.

enum class State {
    Stopped,
    Running
};

State state =
    State::Running;

구조체 태그 이름

C에서는 구조체 태그와 일반 타입 이름이 분리되어 있으므로 struct를 반복하거나 typedef를 사용하는 경우가 많다.

/* C */

struct Position {
    float x;
    float y;
};

struct Position position;

typedef로 별칭을 만들 수 있다.

/* C */

typedef struct Position {
    float x;
    float y;
} Position;

Position position;

C++에서는 클래스 이름 자체가 타입 이름으로 도입된다.

struct Position {
    float x;
    float y;
};

Position position;

C와 C++에서 함께 사용하는 헤더에서는 typedef struct 관용구를 사용할 수 있지만, 불필요한 별칭 중복과 이름 충돌을 고려해야 한다.

지정 초기화

C와 C++는 모두 지정 초기화 문법을 제공하지만 세부 규칙이 동일하지 않다.

/* C */

struct Position position = {
    .y = 20.0F,
    .x = 10.0F
};

C에서는 지정자의 순서에 더 유연한 형태가 허용될 수 있다.

C++20의 지정 초기화에서는 멤버를 클래스 선언 순서에 맞춰 지정해야 한다.

struct Position {
    float x;
    float y;
};

Position position {
    .x = 10.0F,
    .y = 20.0F
};

다음처럼 선언 순서를 거스르는 초기화는 C++에서 허용되지 않는다.

// Position position {
//     .y = 20.0F,
//     .x = 10.0F
// };

가변 길이 배열

C는 선택적이거나 구현 지원에 따라 가변 길이 배열을 제공해 왔다.

/* C */

void process(int count)
{
    int values[count];

    use(values, count);
}

표준 C++에는 가변 길이 내장 배열이 없다.

#include <vector>

void process(int count)
{
    std::vector<int> values(
        static_cast<std::size_t>(
            count
        )
    );

    use(values);
}

일부 C++ 컴파일러는 가변 길이 배열을 확장 기능으로 허용하지만 이식 가능한 표준 C++ 코드로 간주할 수 없다.

복합 리터럴

C는 복합 리터럴을 제공한다.

/* C */

struct Position* position =
    &(struct Position) {
        .x = 10.0F,
        .y = 20.0F
    };

같은 문법은 표준 C++ 문법이 아니다. C++에서는 이름이 있는 객체나 임시 객체를 사용할 수 있다.

Position position {
    .x = 10.0F,
    .y = 20.0F
};

use(position);

배열 매개변수 문법

C는 함수 매개변수의 배열 선언에 static과 한정자를 사용할 수 있다.

/* C */

void process(
    int values[static 16]
);

이는 호출자가 최소 열여섯 개 원소에 접근 가능한 배열을 제공해야 한다는 계약을 나타낼 수 있다.

같은 매개변수 문법은 C++에서 지원되지 않는다.

#include <span>

void process(
    std::span<int, 16> values
);

C++에서는 std::span과 같은 뷰 타입으로 크기와 비소유 범위를 표현할 수 있다.

C++에서 더 엄격한 타입 검사

C++는 C와 유사한 저수준 기능을 제공하면서도 여러 문맥에서 더 엄격한 타입 검사를 수행한다.

함수 원형

int add(int left, int right);

// add("20", 22);

함수 호출 인자가 매개변수 타입과 맞는지 검사한다.

열거형

enum class RendererBackend {
    Vulkan,
    D3D12
};

enum class AudioBackend {
    Native,
    SteamAudio
};

RendererBackend renderer =
    RendererBackend::Vulkan;

// renderer = AudioBackend::Native;

서로 다른 열거형을 암시적으로 혼합하지 못한다.

const

C++에서는 const가 오버로딩, 참조 바인딩과 멤버 함수 계약에 깊게 참여한다.

class Document {
public:
    std::size_t size() const;
    void clear();
};

void inspect(
    const Document& document
)
{
    std::size_t size =
        document.size();

    // document.clear();
}

C에도 const가 있지만 클래스 멤버 함수의 cv 한정이나 참조 오버로딩과 같은 C++ 객체 모델은 없다.

명시적 변환

C 스타일 캐스트는 여러 종류의 변환을 하나의 문법으로 표현한다.

double value = 3.14;

int integer =
    (int)value;

C++는 의도를 구분하는 명시적 변환 연산자를 제공한다.

int integer =
    static_cast<int>(value);

const void* source =
    &integer;

const int* pointer =
    static_cast<
        const int*
    >(source);
  • static_cast는 언어가 정의한 정적 변환을 표현한다.
  • const_cast는 cv 한정을 변경한다.
  • dynamic_cast는 다형적 클래스 계층의 런타임 변환을 수행한다.
  • reinterpret_cast는 저수준 표현 변환을 명시한다.

객체 모델의 차이

C의 구조체는 데이터를 묶는 집계 타입이고, 함수는 구조체 밖에 존재한다.

/* C */

struct Counter {
    int value;
};

void counter_increment(
    struct Counter* counter
)
{
    ++counter->value;
}

C++ 클래스는 데이터와 연산, 생성과 파괴, 접근 제어와 상속 관계를 하나의 타입에 결합할 수 있다.

class Counter {
    int value_ = 0;

public:
    void increment()
    {
        ++value_;
    }

    int value() const noexcept
    {
        return value_;
    }
};

C에서도 함수 포인터와 구조체를 조합해 동적 디스패치를 구현할 수 있다.

/* C */

struct Shape;

struct ShapeVTable {
    double (*area)(
        const struct Shape*
    );

    void (*destroy)(
        struct Shape*
    );
};

struct Shape {
    const struct ShapeVTable*
        vtable;
};

C++는 가상 함수와 소멸자를 언어 차원에서 제공한다.

class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

C++ 구현이 가상 함수 테이블과 유사한 기법을 사용할 수 있지만, 구체적인 객체 배치와 디스패치 방식은 구현체의 ABI에 속한다.

자원 관리 방식

C에서는 일반적으로 자원 획득과 반환을 명시적인 함수 호출로 관리한다.

/* C */

FILE* file =
    fopen(
        "document.txt",
        "rb"
    );

if (file == NULL) {
    return ERROR_OPEN;
}

if (process(file) != 0) {
    fclose(file);

    return ERROR_PROCESS;
}

fclose(file);

return SUCCESS;

오류 경로가 늘어날수록 자원 반환 코드를 반복하거나 공통 정리 지점으로 이동시키는 경우가 많다.

/* C */

int result = ERROR_UNKNOWN;
FILE* file = NULL;
void* buffer = NULL;

file = fopen(
    "document.bin",
    "rb"
);

if (file == NULL) {
    result = ERROR_OPEN;
    goto cleanup;
}

buffer = malloc(BUFFER_SIZE);

if (buffer == NULL) {
    result = ERROR_MEMORY;
    goto cleanup;
}

result =
    process(
        file,
        buffer
    );

cleanup:

free(buffer);

if (file != NULL) {
    fclose(file);
}

return result;

C++는 생성자와 소멸자를 이용해 자원을 객체 수명에 연결하는 RAII를 제공한다.

#include <fstream>
#include <vector>

int process_document()
{
    std::ifstream file(
        "document.bin",
        std::ios::binary
    );

    if (!file) {
        return error_open;
    }

    std::vector<std::byte>
        buffer(buffer_size);

    return process(
        file,
        buffer
    );
}

함수가 어느 경로로 종료되더라도 지역 객체의 소멸자가 실행되어 파일과 동적 저장 공간이 정리된다.

C와 C++의 일반적인 자원 정리 방식
  1. 자원 획득
  2. 오류 가능 작업 수행
  3. C에서는 각 반환 경로나 공통 정리 구역에서 반환
  4. C++에서는 소유 객체의 소멸자가 범위 종료를 감지
  5. 자원 반환

C++에서도 C 스타일 정리 구조를 작성할 수 있고, C에서도 자원별 소유 구조체와 정리 함수를 설계할 수 있다. 차이는 C++가 결정적 소멸과 자동 객체 수명을 언어의 클래스 모델에 통합한다는 데 있다.

메모리 할당

C는 malloc(), calloc(), realloc()free()를 사용해 바이트 저장 공간을 관리한다.

/* C */

int* values =
    malloc(
        count * sizeof(*values)
    );

if (values == NULL) {
    return ERROR_MEMORY;
}

free(values);

이 함수들은 C++에서도 사용할 수 있지만 클래스 생성자와 소멸자를 자동으로 호출하지 않는다.

void* storage =
    std::malloc(
        sizeof(Resource)
    );

// 아직 Resource 생성자는 호출되지 않았다.

C++의 new 표현식은 저장 공간 할당과 객체 초기화를 결합하고, delete는 객체 파괴와 저장 공간 반환을 결합한다.

Resource* resource =
    new Resource(
        configuration
    );

delete resource;

현대 C++에서는 직접 newdelete를 넓게 사용하기보다 값 타입, 컨테이너와 스마트 포인터에 소유권을 맡기는 경우가 많다.

auto resource =
    std::make_unique<Resource>(
        configuration
    );
std::vector<int> values(count);

C의 realloc()은 기존 바이트 저장 공간을 확장하거나 이동할 수 있지만, 비단순 C++ 객체의 생성·이동·파괴 규칙을 대신 처리하지 않는다.

일반 선택과 _Generic

C11은 _Generic을 이용해 표현식 타입에 따른 선택을 지원한다.

/* C */

#define absolute(value) \
    _Generic( \
        (value), \
        int: abs, \
        long: labs, \
        double: fabs \
    )(value)

C++는 함수 오버로딩과 템플릿을 사용한다.

int absolute(int value);
long absolute(long value);
double absolute(double value);
template<class T>
constexpr T absolute(T value)
{
    return value < T {}
        ? -value
        : value;
}

개념으로 허용할 타입을 제한할 수 있다.

#include <concepts>

template<class T>
concept Arithmetic =
    std::integral<T> ||
    std::floating_point<T>;

template<Arithmetic T>
constexpr T absolute(T value)
{
    return value < T {}
        ? -value
        : value;
}

C의 매크로와 _Generic은 번역 전 또는 타입 선택 문맥에서 유용하고, C++ 템플릿은 타입 검사, 오버로드 결정과 인스턴스화에 통합된다.

표준 라이브러리의 관계

C++ 표준 라이브러리는 C 표준 라이브러리의 많은 기능을 호환 헤더 형태로 제공한다.

C 헤더 이름을 그대로 사용할 수 있다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

C++ 형식의 헤더도 제공된다.

#include <cstdio>
#include <cstdlib>
#include <cstring>

C++ 형식 헤더는 대응 기능을 std 이름공간에 제공한다.

std::printf(
    "%s\n",
    "C++"
);

void* memory =
    std::malloc(1024);

std::free(memory);

일부 구현에서는 전역 이름도 함께 제공할 수 있지만 이식 가능한 C++ 코드에서는 해당 C++ 헤더가 보장하는 이름공간 규칙을 따를 수 있다. C++ 표준은 C 호환 헤더와 대응하는 <cname> 헤더의 관계를 별도로 규정한다.[121]

C++ 라이브러리는 C 라이브러리에 더해 다음과 같은 별도 추상화를 제공한다.

  • std::string
  • std::vector
  • 표준 컨테이너
  • 반복자와 알고리즘
  • 스마트 포인터
  • 스트림과 형식 지정
  • 스레드와 원자적 연산
  • 파일 시스템
  • 시간 단위와 달력
  • 범위와 뷰
  • 오류 및 합 타입
  • 템플릿 기반 수치·유틸리티 라이브러리

C와 C++ 코드의 연결

C와 C++는 같은 플랫폼의 객체 파일 형식과 호출 규약을 사용할 수 있지만, C++ 함수 이름은 오버로딩과 이름공간 정보를 표현하기 위해 이름 장식을 사용할 수 있다.

int calculate(int value);
double calculate(double value);

두 함수는 소스 수준에서 같은 이름을 가지므로 링커 심볼에는 매개변수 타입 등을 구분하는 구현체별 이름 장식이 필요할 수 있다.

C 함수와 연결하려면 extern "C"로 C 언어 연결을 지정할 수 있다.

extern "C" {

int library_initialize(void);

void library_shutdown(void);

}

extern "C"는 함수 본문을 C 언어로 바꾸는 기능이 아니다. C++ 코드에서 해당 선언이 C 연결 규칙을 사용하도록 지정한다.

extern "C"
int library_initialize(void)
{
    try {
        initialize_cpp_system();

        return 0;
    }
    catch (...) {
        return -1;
    }
}

함수 본문은 C++로 작성됐지만 외부에서는 C 연결 이름과 C 호환 시그니처를 통해 호출할 수 있다.

공용 헤더

같은 헤더를 C와 C++에서 모두 사용하려면 __cplusplus 매크로로 extern "C"를 조건부 적용할 수 있다.

/* library.h */

#ifndef LIBRARY_H
#define LIBRARY_H

#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct LibraryHandle
    LibraryHandle;

typedef enum LibraryResult {
    LIBRARY_RESULT_SUCCESS = 0,
    LIBRARY_RESULT_INVALID_ARGUMENT = 1,
    LIBRARY_RESULT_OUT_OF_MEMORY = 2,
    LIBRARY_RESULT_INTERNAL_ERROR = 3
} LibraryResult;

LibraryHandle*
library_create(void);

void library_destroy(
    LibraryHandle* library
);

LibraryResult
library_process(
    LibraryHandle* library,
    const void* data,
    size_t size
);

#ifdef __cplusplus
}
#endif

#endif

이 헤더는 C에서 일반 함수 선언으로, C++에서는 C 언어 연결을 가진 함수 선언으로 해석된다. C++ 표준의 C 호환 헤더 규정도 이와 같은 양쪽 언어용 선언 예시를 제공한다.[122]

C 프로그램에서 C++ 라이브러리를 호출하는 과정
  1. C와 C++가 함께 읽을 수 있는 공용 헤더 작성
  2. 공개 함수에 C 언어 연결 지정
  3. C++ 구현에서 내부 객체와 예외 처리
  4. C ABI용 불투명 포인터와 오류 코드로 변환
  5. C 컴파일러로 작성한 호출자와 링킹

불투명 포인터

C ABI에서는 C++ 클래스의 정의를 직접 노출하지 않고 불완전 구조체 포인터로 객체를 나타낼 수 있다.

/* renderer.h */

typedef struct Renderer
    Renderer;

Renderer*
renderer_create(void);

void renderer_destroy(
    Renderer* renderer
);

void renderer_draw(
    Renderer* renderer
);

C++ 구현은 실제 구조체 안에 C++ 객체를 둘 수 있다.

// renderer.cpp

#include "renderer.h"

class RendererImpl {
public:
    void draw()
    {
        draw_frame();
    }
};

struct Renderer {
    RendererImpl implementation;
};

extern "C"
Renderer*
renderer_create(void)
{
    try {
        return new Renderer {};
    }
    catch (...) {
        return nullptr;
    }
}

extern "C"
void renderer_destroy(
    Renderer* renderer
)
{
    delete renderer;
}

extern "C"
void renderer_draw(
    Renderer* renderer
)
{
    if (renderer == nullptr) {
        return;
    }

    renderer->implementation.draw();
}

C 호출자는 내부 C++ 클래스의 크기와 멤버를 알지 못한다.

/* main.c */

#include "renderer.h"

int main(void)
{
    Renderer* renderer =
        renderer_create();

    if (renderer == NULL) {
        return 1;
    }

    renderer_draw(renderer);
    renderer_destroy(renderer);

    return 0;
}

이 구조는 구현 세부 사항과 C++ ABI를 C 인터페이스 뒤에 숨긴다.

ABI 경계

C와 C++의 상호 운용에서 중요한 것은 소스 문법의 유사성뿐 아니라 ABI다.

ABI에는 다음 요소가 포함될 수 있다.

  • 함수 호출 규약
  • 매개변수와 반환값 전달
  • 구조체 배치와 정렬
  • 기본 정수 타입 크기
  • 심볼 이름
  • 예외 처리 방식
  • 메모리 할당과 반환 주체
  • 런타임 라이브러리
  • 스레드 지역 저장소
  • 플랫폼별 객체 파일 형식

C++ 표준은 모든 구현이 공유하는 단일 바이너리 ABI를 규정하지 않는다. 따라서 C++ 클래스, 가상 함수, 표준 컨테이너와 예외를 임의의 C 컴파일러 경계에 직접 노출할 수 없다.

// C API로 직접 노출하기 부적합한 예

extern "C"
std::vector<std::string>
load_names();

C는 std::vectorstd::string의 타입과 수명 규칙을 이해하지 못한다.

다음과 같이 버퍼와 크기를 사용할 수 있다.

size_t
library_name_count(
    const LibraryHandle*
        library
);

size_t
library_name_copy(
    const LibraryHandle*
        library,
    size_t index,
    char* output,
    size_t capacity
);

호출자가 필요한 크기를 먼저 구한 뒤 자신의 저장 공간을 제공하게 할 수 있다.

구조체 공유

C와 C++가 동일한 구조체를 공유할 수 있지만 배치와 표현에 주의해야 한다.

/* shared.h */

#include <stdint.h>

typedef struct PacketHeader {
    uint32_t type;
    uint32_t size;
} PacketHeader;

C++에서 사용할 수 있다.

#include "shared.h"

PacketHeader header {
    .type = 1,
    .size = 128
};

단순한 표준 레이아웃 구조체는 C ABI 경계에서 사용하기 쉽지만 다음 요소는 별도로 확인해야 한다.

  • 대상 플랫폼의 정수 타입 크기
  • 멤버 정렬
  • 구조체 패딩
  • 바이트 순서
  • 컴파일러의 패킹 옵션
  • 비트 필드 배치
  • 포인터 크기
  • 구조체 버전

네트워크나 파일 형식에서는 구조체 메모리 표현을 그대로 전송하기보다 필드를 명시적으로 직렬화하는 방식이 더 이식 가능하다.

void write_header(
    Writer& writer,
    const PacketHeader& header
)
{
    writer.write_u32_le(
        header.type
    );

    writer.write_u32_le(
        header.size
    );
}

메모리 소유권 경계

C와 C++를 연결할 때에는 어느 쪽에서 메모리를 할당하고 어느 쪽에서 반환하는지 명확해야 한다.

다음 인터페이스는 호출자가 반환된 포인터를 어떻게 해제해야 하는지 알기 어렵다.

char*
library_create_text(void);

전용 해제 함수를 함께 제공할 수 있다.

char*
library_create_text(void);

void
library_destroy_text(
    char* text
);
extern "C"
char*
library_create_text(void)
{
    return duplicate_text(
        "C++"
    );
}

extern "C"
void
library_destroy_text(
    char* text
)
{
    release_text(text);
}

할당과 반환을 같은 라이브러리 내부에서 수행하면 서로 다른 힙 구현과 런타임 라이브러리 사이의 문제를 줄일 수 있다.

호출자가 버퍼를 제공하는 방식도 사용할 수 있다.

size_t
library_format_name(
    char* output,
    size_t capacity
);

예외 경계

C에는 C++ 예외 처리 모델이 없으므로 C++ 예외를 extern "C" 함수 밖으로 전파해서는 안 된다.

extern "C"
void library_run(void)
{
    run_cpp_operation();
}

run_cpp_operation()의 예외가 C 호출 경계 밖으로 나가면 호출 환경에서 올바르게 처리할 수 없다.

C ABI 경계에서 예외를 오류 코드로 변환할 수 있다.

extern "C"
LibraryResult
library_run(
    LibraryHandle* library
)
{
    if (library == nullptr) {
        return
            LIBRARY_RESULT_INVALID_ARGUMENT;
    }

    try {
        library->implementation.run();

        return
            LIBRARY_RESULT_SUCCESS;
    }
    catch (const std::bad_alloc&) {
        return
            LIBRARY_RESULT_OUT_OF_MEMORY;
    }
    catch (...) {
        return
            LIBRARY_RESULT_INTERNAL_ERROR;
    }
}

오류 메시지를 별도 함수로 제공할 수 있다.

LibraryResult
library_last_error(
    const LibraryHandle*
        library,
    char* output,
    size_t capacity
);

스레드별 오류 상태를 사용할 수도 있지만 재진입성과 호출 순서를 명확히 해야 한다.

함수 포인터와 콜백

C API는 함수 포인터로 C++ 호출자에게 콜백을 요청할 수 있다.

typedef void
(*LibraryLogCallback)(
    void* user_data,
    int level,
    const char* message
);

void
library_set_log_callback(
    LibraryHandle* library,
    LibraryLogCallback callback,
    void* user_data
);

C++에서는 캡처 없는 람다를 함수 포인터로 변환할 수 있다.

library_set_log_callback(
    library,
    [](
        void* user_data,
        int level,
        const char* message
    ) {
        Logger* logger =
            static_cast<Logger*>(
                user_data
            );

        logger->write(
            level,
            message
        );
    },
    &logger
);

캡처가 있는 일반 람다는 단순 C 함수 포인터로 직접 변환되지 않는다. void* user_data를 함께 전달해 상태를 복원하는 방식이 사용된다.

콜백 수명은 명확해야 한다.

Logger logger;

library_set_log_callback(
    library,
    callback,
    &logger
);

// library가 callback을 사용할 동안
// logger가 살아 있어야 한다.

콜백이 다른 스레드에서 호출되는지, 재진입할 수 있는지, 호출 중 라이브러리를 파괴할 수 있는지도 API 계약에 포함해야 한다.

C++에서 C 라이브러리 사용

C++는 운영체제와 네이티브 라이브러리의 C API를 직접 호출하는 데 널리 사용된다.

NativeHandle* handle =
    native_create();

if (handle == nullptr) {
    throw NativeError();
}

native_run(handle);
native_destroy(handle);

RAII 래퍼를 만들어 C 자원을 C++ 객체 수명에 연결할 수 있다.

class NativeObject {
    NativeHandle* handle_ =
        nullptr;

public:
    NativeObject()
        : handle_(native_create())
    {
        if (handle_ == nullptr) {
            throw NativeError();
        }
    }

    ~NativeObject()
    {
        native_destroy(handle_);
    }

    NativeObject(
        const NativeObject&
    ) = delete;

    NativeObject& operator=(
        const NativeObject&
    ) = delete;

    NativeObject(
        NativeObject&& other
    ) noexcept
        : handle_(
              std::exchange(
                  other.handle_,
                  nullptr
              )
          )
    {
    }

    void run()
    {
        native_run(handle_);
    }
};

사용자는 C API의 생성·파괴 함수를 직접 반복하지 않는다.

void process()
{
    NativeObject object;

    object.run();
}

사용자 정의 삭제자를 가진 std::unique_ptr도 사용할 수 있다.

struct NativeDeleter {
    void operator()(
        NativeHandle* handle
    ) const noexcept
    {
        native_destroy(handle);
    }
};

using NativeOwner =
    std::unique_ptr<
        NativeHandle,
        NativeDeleter
    >;

NativeOwner handle(
    native_create()
);

C에서 C++ 라이브러리 사용

C++ 라이브러리를 C에서 사용하려면 공개 인터페이스를 C가 이해할 수 있는 형태로 제한한다.

C 호환 공개 인터페이스에 적합한 요소
  • C ABI 공개 경계
    • 사용할 수 있는 대표 요소
      • 정수형과 부동소수점형
      • char 버퍼
      • 단순 구조체
      • 열거형 또는 정수 오류 코드
      • 불투명 구조체 포인터
      • 함수 포인터
      • 크기와 버퍼의 쌍
    • 직접 노출하기 어려운 C++ 요소
      • 클래스 상속 계층
      • 가상 함수
      • 함수 오버로딩
      • 템플릿
      • 예외
      • 참조
      • 표준 컨테이너
      • C++ 표준 라이브러리 ABI

내부에서는 자유롭게 C++ 기능을 사용할 수 있다.

class Library {
    std::vector<Resource>
        resources_;

    std::unique_ptr<Backend>
        backend_;

public:
    Result process(
        std::span<const std::byte>
            data
    );
};

외부에는 C 형식으로 변환한다.

struct LibraryHandle {
    Library library;
};

extern "C"
LibraryHandle*
library_create(void)
{
    try {
        return new LibraryHandle {};
    }
    catch (...) {
        return nullptr;
    }
}

헤더를 C와 C++에서 함께 사용할 때

공용 헤더는 두 언어의 공통 문법만 사용해야 한다.

적합한 형태는 다음과 같다.

#include <stddef.h>
#include <stdint.h>

typedef struct ImageView {
    const uint8_t* data;
    size_t width;
    size_t height;
    size_t stride;
} ImageView;

다음 C++ 전용 요소는 C 헤더에 직접 사용할 수 없다.

// C에서는 해석할 수 없는 선언

// class Image;
// std::vector<int> values;
// void process(Image& image);
// template<class T> void function(T value);
// namespace graphics {}

C++ 전용 편의 함수를 조건부로 추가할 수는 있다.

typedef struct Vector2 {
    float x;
    float y;
} Vector2;

#ifdef __cplusplus

inline Vector2
operator+(
    Vector2 left,
    Vector2 right
)
{
    return Vector2 {
        left.x + right.x,
        left.y + right.y
    };
}

#endif

다만 공용 ABI 구조체의 의미가 C와 C++에서 달라지지 않도록 주의해야 한다.

이름 장식과 오버로딩

C++는 함수 오버로딩을 지원한다.

void process(int value);
void process(double value);
void process(const char* value);

링커가 세 함수를 구분하려면 구현체가 이름과 타입 정보를 조합한 심볼을 만들 수 있다.

C는 같은 범위에서 같은 이름의 함수 오버로딩을 지원하지 않는다.

/* C */

void process_int(int value);
void process_double(double value);
void process_text(
    const char* value
);

C ABI로 내보내는 C++ 함수도 서로 다른 이름을 사용해야 한다.

extern "C" {

void process_int(int value);

void process_double(double value);

void process_text(
    const char* value
);

}

restrict와 별칭

C는 restrict 한정자를 통해 특정 포인터가 접근하는 객체에 대한 별칭 제약을 표현할 수 있다.

/* C */

void add_arrays(
    float* restrict output,
    const float* restrict left,
    const float* restrict right,
    size_t count
);

표준 C++에는 C의 restrict와 동일한 표준 키워드가 없다. 컴파일러별 확장을 사용하거나 알고리즘 구조와 타입을 통해 별칭 가능성을 줄일 수 있다.

void add_arrays(
    std::span<float> output,
    std::span<const float> left,
    std::span<const float> right
);

std::span은 범위와 크기를 표현하지만 서로 겹치지 않는다는 사실까지 자동으로 보장하지는 않는다.

원자적 연산과 메모리 모델

C11과 C++11은 모두 언어 차원의 메모리 모델과 원자적 연산을 도입했다. 두 위원회는 스레드와 원자적 메모리 모델의 호환성을 중요한 목표로 다뤄 왔다.

C++ 표준은 C++ 메모리 모델이 C의 메모리 모델과 호환되는 것을 의도한다고 명시한다.[123]

C의 원자 타입은 <stdatomic.h>를 중심으로 제공된다.

/* C */

#include <stdatomic.h>

atomic_int counter = 0;

void increment(void)
{
    atomic_fetch_add(
        &counter,
        1
    );
}

C++는 <atomic>의 클래스 템플릿과 멤버 함수를 제공한다.

#include <atomic>

std::atomic<int> counter = 0;

void increment()
{
    counter.fetch_add(
        1,
        std::memory_order_relaxed
    );
}

개념과 메모리 순서가 유사하더라도 C 원자 타입과 C++ 원자 타입의 구체적 소스 표현과 ABI가 모든 구현에서 자동으로 동일하다고 가정해서는 안 된다. 공용 ABI에 원자 타입을 직접 넣을 때에는 대상 구현의 보장을 확인해야 한다.

C와 C++의 호환성 문제를 함께 검토하는 공동 연구 그룹도 운영되어 왔다.[124]

C23과 현대 C++의 차이

C와 C++는 공통 기반을 유지하면서 각각 독립적으로 발전한다. C23은 nullptr, typeof, 속성 문법, 향상된 열거형과 여러 현대화 기능을 도입했지만, 같은 철자나 유사한 문법이 C++와 완전히 같은 의미를 가진다고 단정할 수는 없다.

C++는 다음과 같은 방향을 중심으로 발전했다.

  • 클래스와 객체 수명
  • 템플릿과 개념
  • 오버로딩
  • 예외 처리
  • RAII
  • 범위와 알고리즘
  • 모듈
  • 코루틴
  • 정적·동적 다형성

C는 다음과 같은 방향을 유지한다.

  • 비교적 작은 핵심 언어
  • 명시적인 데이터 구조와 함수
  • 단순한 C ABI
  • 시스템 및 임베디드 환경
  • 다른 언어와 연결하기 쉬운 인터페이스
  • 구현 부담이 작은 런타임 모델

새 C 표준의 기능이 C++에 자동으로 들어가거나, 새 C++ 기능이 C에 자동으로 들어가는 것은 아니다. 양쪽 위원회는 호환성이 중요한 기능을 함께 검토할 수 있지만 각 언어의 설계와 표준 채택 절차는 독립적이다.

C와 C++ 중립 코드

일부 저수준 헤더와 라이브러리는 C와 C++ 모두에서 번역되는 공통 소스 부분을 유지한다.

#ifndef COMMON_H
#define COMMON_H

#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct BufferView {
    const uint8_t* data;
    size_t size;
} BufferView;

int
buffer_validate(
    BufferView buffer
);

#ifdef __cplusplus
}
#endif

#endif

공통 코드에서는 다음 요소를 피하거나 조건부로 분리할 수 있다.

  • C++ 키워드와 충돌하는 식별자
  • C 전용 가변 길이 배열
  • C 전용 복합 리터럴
  • C++ 클래스와 템플릿
  • C++ 참조
  • 서로 다른 지정 초기화 규칙
  • 언어별 원자 타입 표현
  • 언어별 예외 처리
  • 구현 확장에만 의존하는 문법

모든 구현을 공통 문법으로 제한할 필요는 없다. 공용 ABI 헤더만 두 언어에 맞추고, 실제 구현은 C 또는 C++ 각각의 장점을 사용하는 구조가 더 단순할 수 있다.

include/
└── library.h
    └── C와 C++ 공용 ABI

src/
├── library_c.c
└── library_cpp.cpp
    └── 언어별 구현

C++를 “C with Classes”로 보는 관점의 한계

C++는 역사적으로 C에 클래스를 추가하면서 시작했지만 현대 C++를 단순히 “클래스가 있는 C”로 설명하면 언어의 핵심 부분을 놓치게 된다.

현대 C++에는 다음과 같은 C에 없는 중심 기능이 있다.

  • 결정적 소멸과 RAII
  • 값 타입 기반 사용자 정의 추상화
  • 템플릿과 개념
  • 이동 의미론
  • 함수 및 연산자 오버로딩
  • 예외 처리
  • 표준 컨테이너와 알고리즘
  • 범위와 뷰
  • 모듈
  • 코루틴
  • 클래스 계층과 가상 함수
  • constexpr 기반 컴파일 시간 프로그래밍
#include <algorithm>
#include <memory>
#include <ranges>
#include <string>
#include <vector>

class Document {
    std::string name_;
    std::vector<int> values_;

public:
    Document(
        std::string name,
        std::vector<int> values
    )
        : name_(
              std::move(name)
          ),
          values_(
              std::move(values)
          )
    {
    }

    auto positive_values() const
    {
        return values_
            | std::views::filter(
                  [](int value) {
                      return value > 0;
                  }
              );
    }
};

auto document =
    std::make_unique<Document>(
        "C++",
        std::vector<int> {
            -1,
            10,
            20
        }
    );

이 코드는 C와 유사한 기계 모델 위에서 동작하지만 C 소스에 클래스를 표면적으로 추가한 정도로는 설명하기 어렵다.

반대로 C++ 코드에서 클래스와 템플릿을 사용하지 않는다고 해서 그 코드가 자동으로 C가 되는 것도 아니다.

auto calculate(
    auto left,
    auto right
)
{
    return left + right;
}

이 함수는 클래스 계층을 사용하지 않지만 C++ 템플릿 문법을 사용한다.

C 스타일 C++의 문제

C++에서 C 문법과 수동 자원 관리만 사용해도 프로그램을 작성할 수 있다.

void process()
{
    Resource* resource =
        static_cast<Resource*>(
            std::malloc(
                sizeof(Resource)
            )
        );

    if (resource == nullptr) {
        return;
    }

    initialize_resource(
        resource
    );

    use_resource(
        resource
    );

    destroy_resource(
        resource
    );

    std::free(resource);
}

그러나 C++ 객체의 생성과 파괴, 예외, 이동과 소유권 타입을 고려하지 않은 코드는 언어가 제공하는 자원 관리 기능을 활용하지 못할 수 있다.

void process()
{
    Resource resource;

    resource.use();
}

C 스타일이 항상 잘못된 것은 아니다. 다음 상황에서는 C와 유사한 데이터 중심 인터페이스가 적합할 수 있다.

  • 운영체제 ABI 경계
  • 네트워크 패킷 구조
  • 하드웨어 레지스터 표현
  • 플러그인 C ABI
  • 다른 언어에서 호출할 공개 API
  • 단순한 POD 형식 데이터
  • C 라이브러리 래퍼 내부

중요한 점은 C++ 내부 전체를 C 문법의 최소 공통분모로 제한하는 것과, 필요한 경계에서 C 호환 인터페이스를 사용하는 것을 구분하는 것이다.

C++ 헤더를 C에서 직접 사용할 수 없는 이유

C++ 헤더는 다음과 같은 언어 요소를 포함할 수 있다.

namespace graphics {

template<class T>
class Handle {
public:
    T value;
};

class Device {
public:
    virtual void submit() = 0;
    virtual ~Device() = default;
};

}

C 컴파일러는 이름공간, 템플릿, 클래스와 가상 함수를 해석할 수 없다.

C++ 라이브러리가 C API를 제공하려면 별도의 공용 헤더를 둔다.

library/
├── include/
│   ├── library.hpp
│   │   └── C++ 전용 인터페이스
│   └── library.h
│       └── C 호환 인터페이스
└── src/
    ├── library.cpp
    └── library_c_api.cpp

두 인터페이스가 같은 내부 구현을 사용할 수 있다.

C와 C++ 혼합 프로젝트

하나의 프로젝트에서 C와 C++ 소스를 별도 컴파일한 뒤 연결할 수 있다.

legacy.c
↓ C 컴파일러
legacy.o

engine.cpp
↓ C++ 컴파일러
engine.o

main.cpp
↓ C++ 컴파일러
main.o

legacy.o + engine.o + main.o
↓ C++ 링커 드라이버
실행 파일

C++ 런타임과 표준 라이브러리가 필요한 경우 최종 링크에는 일반적으로 C++ 컴파일러 드라이버를 사용하는 편이 필요한 런타임 라이브러리를 자동으로 연결하기 쉽다.

CMake에서는 언어를 함께 활성화할 수 있다.

cmake_minimum_required(
    VERSION 3.25
)

project(
    MixedProject
    LANGUAGES
        C
        CXX
)

add_library(
    legacy
    STATIC
    legacy.c
)

add_library(
    engine
    STATIC
    engine.cpp
)

target_link_libraries(
    engine
    PRIVATE
        legacy
)

add_executable(
    application
    main.cpp
)

target_link_libraries(
    application
    PRIVATE
        engine
)

공용 헤더에는 extern "C" 조건부 처리가 필요하다.

어느 언어를 선택할 것인가

C와 C++는 같은 저수준 시스템 영역에서 사용되지만 추상화와 프로그램 구성 방식이 다르다.

기준CC++
기본 추상화 단위구조체와 함수값 타입, 클래스, 함수와 템플릿
자원 관리명시적 획득·반환RAII와 소유권 타입
제네릭 기능매크로, _Generic, void* 기반 구조템플릿과 개념
다형성함수 포인터와 수동 테이블가상 함수, 템플릿, 타입 소거
오류 전달오류 코드와 상태오류 코드, 합 타입과 예외
표준 컨테이너제공하지 않음다수 제공
ABI 경계비교적 단순구현체와 설정에 더 민감
다른 언어와 연결C ABI가 널리 사용됨일반적으로 C 래퍼를 제공
언어 규모상대적으로 작음기능과 표준 라이브러리가 큼
컴파일 시간 추상화제한적템플릿, constexpr, 개념

C가 적합할 수 있는 경우는 다음과 같다.

  • 극도로 제한된 컴파일러와 런타임 환경
  • C ABI 자체가 제품의 핵심인 라이브러리
  • 기존 C 코드베이스와 직접 통합하는 프로젝트
  • 언어 및 도구 체계의 최소 규모가 중요한 환경
  • 운영체제나 펌웨어의 C 중심 구성 요소

C++가 적합할 수 있는 경우는 다음과 같다.

  • 복잡한 자원과 객체 수명을 안전하게 관리해야 하는 시스템
  • 값 타입과 제네릭 알고리즘이 필요한 대형 코드베이스
  • 성능을 유지하면서 높은 수준의 추상화를 사용하려는 프로그램
  • 다양한 자료구조와 표준 알고리즘을 활용하는 네이티브 애플리케이션
  • 정적·동적 다형성을 함께 사용하는 엔진과 프레임워크

두 언어를 함께 사용할 수도 있다.

C ABI 경계
├── 외부 플러그인과 다른 언어
├── 운영체제 및 장치 API
└── 안정적인 공개 함수 집합

C++ 내부 구현
├── RAII
├── 템플릿
├── 표준 컨테이너
├── 비동기 실행
└── 객체 및 자원 수명 관리

호환성의 의미

C와 C++의 호환성은 하나의 단순한 참·거짓 속성이 아니다.

다음과 같은 여러 수준이 있다.

  • 소스 호환성: 같은 소스를 두 언어에서 번역할 수 있는가
  • 헤더 호환성: 같은 선언 헤더를 두 언어에서 읽을 수 있는가
  • 링크 호환성: 별도 컴파일한 목적 파일을 연결할 수 있는가
  • ABI 호환성: 함수 호출과 데이터 배치를 동일하게 해석하는가
  • 라이브러리 호환성: 한 언어의 라이브러리를 다른 언어에서 사용할 수 있는가
  • 의미 호환성: 같은 소스가 두 언어에서 같은 동작을 하는가
struct Position {
    int x;
    int y;
};

이 선언은 소스 수준에서 두 언어와 쉽게 공유할 수 있다.

int size =
    sizeof('A');

이 코드는 두 언어에서 번역될 수 있지만 문자 리터럴 타입 차이 때문에 결과가 달라질 수 있다.

따라서 “컴파일된다”는 사실만으로 동일한 의미와 ABI까지 보장되지는 않는다.

공동 표준화와 분화

C와 C++ 표준화 위원회는 각자 언어를 독립적으로 발전시키지만, 공통 헤더, 원자적 연산, 문자와 수치 형식, 전처리기와 ABI 경계처럼 호환성이 중요한 영역을 함께 검토한다.

WG14와 WG21 사이에는 C and C++ Compatibility Study Group이 구성되어 양쪽 언어의 변경이 상호 호환성에 미치는 영향을 논의해 왔다.[125]

공동 검토의 목표는 두 언어를 하나로 합치는 것이 아니라 다음 문제를 줄이는 데 있다.

  • 같은 기능에 불필요하게 다른 문법이 생기는 문제
  • 공용 헤더를 작성하기 어려워지는 문제
  • 동일한 메모리 모델 개념이 충돌하는 문제
  • C 라이브러리를 C++에서 사용하기 어려워지는 문제
  • 기존 소스가 새 표준에서 예상치 못하게 깨지는 문제

비야네 스트롭스트룹도 C와 C++를 하나의 언어로 병합하는 방안보다는 두 언어의 독립성과 호환성 사이의 균형을 설명해 왔다.[126]

관계의 정리

C++는 C로부터 다음 기반을 계승했다.

  • 표현식과 문장 중심 문법
  • 함수와 별도 컴파일
  • 포인터와 메모리 주소
  • 구조체와 공용체
  • 전처리기
  • 시스템 API와 하드웨어 접근
  • 기계 수준 비용을 드러내는 실행 모델

C++는 이 기반 위에 다음 기능을 추가하거나 확장했다.

  • 클래스와 접근 제어
  • 생성자와 소멸자
  • RAII
  • 함수 및 연산자 오버로딩
  • 참조
  • 상속과 가상 함수
  • 템플릿과 개념
  • 예외 처리
  • 이름공간과 모듈
  • 표준 컨테이너와 알고리즘
  • 강한 타입을 가진 사용자 정의 추상화
  • 컴파일 시간 프로그래밍

두 언어는 상당한 문법과 구현 기반을 공유하지만 서로의 별칭이나 단순한 버전 관계는 아니다. C++는 C와의 호환성을 중요한 설계 조건으로 유지해 왔으나, 더 강한 타입 검사와 독립된 객체·템플릿 모델을 발전시키면서 별도의 언어가 되었다.

C와 C++를 함께 사용하는 시스템에서는 C ABI를 경계로 두고, C++ 내부에서는 RAII, 값 타입과 표준 라이브러리를 사용하는 구성이 일반적이다. 이를 통해 외부에는 단순하고 널리 연결 가능한 인터페이스를 제공하면서 내부에서는 C++의 추상화와 자원 관리 기능을 활용할 수 있다.

표준과 구현체

C++는 특정 기업의 컴파일러나 하나의 운영체제에 의해 정의되는 언어가 아니라 국제 표준으로 규정되는 프로그래밍 언어다. C++ 표준은 소스 코드의 문법과 의미, 타입과 객체의 동작, 메모리 모델, 번역 과정, 실행 환경과 표준 라이브러리의 요구 사항을 정의한다. GCC, Clang과 Microsoft Visual C++ 같은 구현체는 이러한 규격을 실제 하드웨어와 운영체제에서 사용할 수 있도록 구현한다.

현재 발행된 정식 C++ 표준은 C++23이며 공식 문서명은 ISO/IEC 14882:2024 — Programming Languages — C++다. C++23이라는 통상 명칭은 기술 작업과 국제 승인이 2023년에 완료된 데서 유래하며, ISO 문서는 행정적인 발행 절차를 거쳐 2024년판으로 출판되었다.[127]

C++26은 C++23의 다음 개정판으로 개발되고 있다. 2026년 현재 위원회 문서와 작업 초안에는 C++26을 위한 기능이 반영되고 있지만, C++26은 아직 ISO가 발행한 정식 국제 표준이 아니다.[128][129]

C++ 표준과 구현체의 관계
  • C++ 국제 표준
    • 핵심 언어
      • 어휘와 문법
      • 타입과 객체 모델
      • 표현식과 문장
      • 템플릿과 개념
      • 예외와 상수 평가
      • 메모리 모델
      • 번역과 실행 규칙
    • 표준 라이브러리
      • 컨테이너와 알고리즘
      • 문자열과 입출력
      • 메모리 관리
      • 동시성
      • 수치 계산
      • 실행 지원
    • 실제 구현체
      • 컴파일러 프런트엔드
      • 최적화기
      • 기계어 생성기
      • 표준 라이브러리 구현
      • 런타임 라이브러리
      • 어셈블러와 링커
      • 플랫폼별 ABI

국제 표준

C++ 국제 표준은 ISO와 IEC의 공동 기술 위원회인 ISO/IEC JTC 1의 하위 조직에서 관리된다. 프로그래밍 언어와 관련 규격을 담당하는 SC 22 아래에서 WG21이 C++ 표준화 작업을 수행한다.[130]

C++ 표준이 규정하는 주요 영역은 다음과 같다.

  • 소스 파일을 구성하는 문자와 토큰
  • 선언, 표현식과 문장의 문법
  • 타입 체계와 타입 변환
  • 객체의 저장 공간과 수명
  • 함수 호출과 오버로드 결정
  • 클래스와 상속
  • 템플릿 인스턴스화와 이름 탐색
  • 예외 처리
  • 상수 평가
  • 스레드와 원자적 메모리 모델
  • 전처리와 번역 단계
  • 프로그램의 시작과 종료
  • 표준 라이브러리 헤더와 모듈
  • 라이브러리 타입과 함수의 의미
  • 알고리즘의 복잡도 요구 사항
  • C와의 호환성
  • 구현체가 선택하거나 문서화할 수 있는 동작

표준은 특정 C++ 프로그램을 어떤 기계어 명령으로 변환해야 하는지 직접 정하지 않는다. 대신 C++ 추상 기계에서 프로그램이 가져야 하는 의미와 관찰 가능한 동작을 규정한다.

#include <iostream>

int main()
{
    std::cout
        << "C++"
        << '\n';
}

표준은 std::cout이 어떤 의미를 가지며 출력 연산이 어떤 상태 변화를 일으키는지를 규정한다. 실제로 Linux의 write() 시스템 호출을 사용할지, Windows 콘솔 API를 사용할지, 별도의 스트림 버퍼를 사용할지는 구현체와 실행 환경이 결정한다.

표준 개정판

C++ 표준은 새로운 개정판을 통해 기능을 추가하고 기존 규칙을 보완한다.

통상 명칭국제 표준주요 의미
C++98ISO/IEC 14882:1998최초의 국제 C++ 표준
C++03ISO/IEC 14882:2003C++98의 수정과 정리
C++11ISO/IEC 14882:2011이동 의미론, 람다, 스레드와 현대 C++ 기반
C++14ISO/IEC 14882:2014C++11의 보완과 사용성 개선
C++17ISO/IEC 14882:2017구조적 바인딩, 파일 시스템과 라이브러리 확장
C++20ISO/IEC 14882:2020개념, 범위, 모듈과 코루틴
C++23ISO/IEC 14882:2024현재 발행된 정식 표준
C++26개발 중차기 개정판

C++11 이후에는 대체로 3년을 주기로 새로운 표준 개정판을 완성하는 일정이 유지되고 있다. 그러나 표준 이름의 연도와 ISO 문서가 실제로 출판되는 연도는 다를 수 있다.

C++ 표준 개정 과정
  1. 언어 또는 라이브러리 문제 제기
  2. WG21 제안서 작성
  3. 연구 그룹과 진화 작업 그룹 검토
  4. 구현 경험과 설계 수정
  5. 표준 문구 검토
  6. WG21 전체 투표
  7. 작업 초안에 변경 사항 반영
  8. 국가 표준 기관의 국제 투표
  9. ISO 국제 표준 발행

새로운 표준이 발행돼도 이전 표준이 즉시 사라지는 것은 아니다. 프로젝트는 사용하는 컴파일러와 플랫폼에 따라 C++17이나 C++20을 계속 기준으로 삼을 수 있다.

표준 문서의 종류

C++와 관련된 문서가 모두 같은 권위를 가지는 것은 아니다.

C++ 표준화 문서
  • 발행된 국제 표준
    • ISO가 발행한 규범 문서
    • 특정 개정판의 최종 규격
  • 작업 초안
    • 다음 표준을 편집 중인 문서
    • 위원회에서 채택된 변경 사항 포함
    • 회의와 투표에 따라 계속 변경
  • WG21 제안서
    • 새 기능 또는 변경 제안
    • 설계와 표준 문구 제시
    • 공개됐다고 해서 채택된 것은 아님
  • 결함 보고서
    • 기존 표준의 오류와 모호성 기록
    • 핵심 언어 결함
    • 표준 라이브러리 결함
  • 기술 명세
    • 실험적인 별도 규격
    • 이후 표준에 통합되거나 폐기될 수 있음

국제 표준

발행된 ISO/IEC 14882 문서는 특정 시점의 정식 C++ 규격이다. 규범적인 정확성이 필요한 경우 최종 판단 기준이 된다.

ISO 표준 문서는 일반적으로 유료로 판매된다. 표준화 위원회가 공개하는 거의 동일한 최종 작업 초안이나 후속 작업 초안은 무료로 열람할 수 있지만, 법적으로 발행된 ISO 문서 그 자체와는 구분된다.

작업 초안

작업 초안은 WG21 편집자가 관리하는 다음 표준의 기반 문서다. 공개 작업 초안에는 위원회가 채택한 변경 사항이 계속 반영된다.

작업 초안의 내용은 아직 국제 투표를 통과한 최종 표준이 아니므로 이후 회의에서 수정되거나 제거될 수 있다.

#if defined(__cpp_impl_reflection)

consteval auto
type_information()
{
    return ^^Type;
}

#endif

차기 표준의 실험 기능을 사용한 코드는 문법과 지원 방식이 변경될 수 있다.

제안서

WG21 제안서는 일반적으로 P 번호와 개정 번호로 식별한다.

P2300R10

P2300은 문서 번호이고 R10은 열 번째 개정판이라는 뜻이다.

하나의 제안서는 위원회 검토 과정에서 여러 번 수정될 수 있다. 문서 번호만 같더라도 개정판에 따라 설계와 표준 문구가 달라질 수 있다.

제안서의 일반적인 상태 변화는 다음과 같다.

WG21 제안서의 진행
  1. 아이디어와 초기 초안
  2. 관련 연구 그룹 검토
  3. 언어 또는 라이브러리 진화 검토
  4. 설계 수정과 구현 경험 수집
  5. 핵심 언어 또는 라이브러리 문구 검토
  6. WG21 전체 투표
  7. 작업 초안 채택 또는 반려

인터넷 기사에서 어떤 기능을 “C++26 기능”이라고 소개하더라도 실제로는 단순 제안 단계일 수 있다. 작업 초안에 채택됐는지와 최종 표준에 유지되는지를 따로 확인해야 한다.

표준화 조직

WG21은 하나의 단일 회의체로만 동작하지 않고 분야별 하위 그룹을 통해 제안을 검토한다.

WG21 내부 검토 영역
  • WG21
    • 핵심 언어
      • 언어 진화
      • 핵심 언어 문구
    • 표준 라이브러리
      • 라이브러리 진화
      • 라이브러리 문구
    • 연구 그룹
      • 동시성과 병렬 실행
      • 임베디드와 독립 실행 환경
      • 수치 계산
      • 텍스트와 유니코드
      • 반사와 메타프로그래밍
      • 안전성과 보안
      • 저지연 시스템
    • 전체 위원회
      • 작업 초안 채택 투표

제안이 관련 하위 그룹의 승인을 받았더라도 즉시 표준이 되는 것은 아니다. 표준 문구 검토와 전체 위원회 투표, 국제 승인 절차를 거쳐야 한다.

결함 보고서

발행된 표준에서도 모순, 모호한 문구와 구현하기 어려운 규칙이 발견될 수 있다. 이러한 문제는 결함 보고서로 관리된다.

핵심 언어 문제는 Core Working Group의 이슈 목록에서, 표준 라이브러리 문제는 Library Working Group의 이슈 목록에서 관리된다.[131][132]

결함 수정 과정
  1. 표준 문구 또는 구현상의 문제 발견
  2. 결함 보고서 등록
  3. 관련 작업 그룹 분석
  4. 수정 문구 작성
  5. 위원회 승인
  6. 구현체와 다음 표준에 반영

결함 수정은 반드시 다음 언어 모드에서만 적용되는 새 기능과 같지 않다. 구현체는 C++17이나 C++20 모드에도 결함 해결 내용을 소급 적용할 수 있다.

따라서 같은 -std=c++20 옵션을 사용하더라도 컴파일러 버전에 따라 세부 해석과 진단이 달라질 수 있다.

핵심 언어와 표준 라이브러리

C++ 구현의 표준 지원은 핵심 언어와 표준 라이브러리로 구분해 확인해야 한다.

다음 코드에는 핵심 언어 기능과 라이브러리 기능이 함께 사용된다.

#include <vector>

template<class T>
T sum(const std::vector<T>& values)
{
    T result {};

    for (const T& value : values) {
        result += value;
    }

    return result;
}

핵심 언어에 속하는 요소는 다음과 같다.

  • 함수 템플릿
  • 타입 매개변수
  • 참조
  • 범위 기반 for
  • 초기화
  • 함수 호출과 반환
  • 연산자 해석

표준 라이브러리에 속하는 요소는 다음과 같다.

  • std::vector
  • 벡터의 반복자
  • 원소 저장 공간 관리
  • 라이브러리 예외
  • 할당자 인터페이스

컴파일러 프런트엔드가 C++23 문법을 지원한다고 해서 설치된 표준 라이브러리가 C++23의 모든 라이브러리 기능을 제공하는 것은 아니다.

#include <print>

int main()
{
    std::println(
        "C++23"
    );
}

컴파일러가 이 소스의 문법을 이해하더라도 표준 라이브러리에 <print>가 구현되지 않았다면 헤더를 찾지 못하거나 필요한 선언을 제공하지 못한다.

구현체의 구성

일반적인 C++ 도구 체계는 여러 구성 요소로 이루어진다.

C++ 구현체의 구성 요소
  • C++ 도구 체계
    • 전처리기
      • 포함 파일과 매크로 처리
    • 컴파일러 프런트엔드
      • 구문 분석
      • 이름 탐색
      • 타입 검사
      • 오버로드 결정
      • 템플릿 인스턴스화
      • 상수 평가
    • 최적화기
      • 대상 독립 최적화
      • 대상별 최적화
    • 코드 생성기
      • 기계어와 목적 파일 생성
    • 표준 라이브러리
      • 헤더와 모듈
      • 템플릿 구현
      • 바이너리 라이브러리
    • 런타임
      • 예외 처리
      • RTTI
      • 초기화와 종료
      • 메모리 할당 지원
    • 링커
      • 목적 파일과 라이브러리 결합
일반적인 C++ 번역 과정
  1. C++ 소스 입력
  2. 전처리
  3. 토큰화와 구문 분석
  4. 이름 탐색과 타입 검사
  5. 템플릿 인스턴스화
  6. 중간 표현 생성
  7. 최적화
  8. 대상 기계어 생성
  9. 목적 파일 생성
  10. 링킹
  11. 실행 파일 또는 라이브러리 생성

실제 컴파일러 내부에서는 여러 단계가 통합되거나 반복될 수 있다. 이 흐름은 각 구성 요소의 역할을 개념적으로 나타낸 것이다.

GCC

GCC는 GNU Compiler Collection의 C++ 구현을 제공한다. C++ 소스를 컴파일할 때 일반적으로 g++ 드라이버를 사용한다.

g++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp \
    -o application

GCC의 C++ 구현은 일반적으로 다음 구성 요소와 함께 사용된다.

GCC C++ 도구 체계
  • GCC
    • C++ 프런트엔드
    • GCC 중간 표현과 최적화기
    • 대상별 코드 생성기
    • libstdc++
    • libgcc
    • 어셈블러
    • 링커

GCC는 표준 중심 언어 모드와 GNU 확장 언어 모드를 구분한다.

g++ \
    -std=c++23 \
    main.cpp
g++ \
    -std=gnu++23 \
    main.cpp

-std=c++23은 ISO C++23을 중심으로 하는 모드이고, -std=gnu++23은 C++23에 GNU 확장을 추가한 모드다.

GCC는 C++ 표준별 핵심 언어 기능 지원 상태를 공식 표로 공개한다. C++23과 C++26 기능은 제안서 단위로 구현 버전이 표시된다.[133]

Clang

Clang은 LLVM 프로젝트의 C, C++와 Objective-C 계열 컴파일러 프런트엔드다.

clang++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp \
    -o application

Clang은 일반적으로 LLVM의 중간 표현과 최적화기, 대상별 코드 생성기를 사용한다.

Clang 기반 도구 체계
  • Clang
    • Clang C++ 프런트엔드
    • LLVM IR
    • LLVM 최적화기
    • LLVM 코드 생성기
    • 선택 가능한 표준 라이브러리
      • libc++
      • libstdc++

Clang 자체와 표준 라이브러리는 독립적으로 선택될 수 있다. Linux에서는 Clang 프런트엔드와 libstdc++를 조합할 수 있고, libc++가 설치된 환경에서는 libc++를 선택할 수 있다.

clang++ \
    -std=c++23 \
    -stdlib=libc++ \
    main.cpp

Clang의 공식 상태 문서는 C++23을 일부 지원하며 -std=c++23으로 해당 모드를 사용할 수 있다고 설명한다. 개별 기능의 완성 여부는 상태표에서 따로 확인해야 한다.[134]

Microsoft Visual C++

Microsoft Visual C++는 Windows와 Visual Studio 도구 체계에서 사용되는 C++ 구현이다. 컴파일러 실행 파일은 일반적으로 cl.exe다.

cl ^
    /std:c++20 ^
    /EHsc ^
    /permissive- ^
    /W4 ^
    main.cpp

MSVC 도구 체계는 다음 구성 요소를 포함한다.

Microsoft C++ 도구 체계
  • Microsoft Visual C++
    • MSVC C++ 컴파일러
    • Microsoft 최적화기
    • Microsoft 코드 생성기
    • Microsoft C++ Standard Library
    • Universal CRT
    • MSVC 런타임
    • Windows SDK
    • Microsoft 링커

Microsoft는 C++ 핵심 언어와 표준 라이브러리의 기능별 적합성 표를 제공한다. 지원 여부는 Visual Studio와 MSVC Build Tools 버전별로 표시된다.[135]

2026년 현재 MSVC는 C++23 기능을 계속 보완하고 있다. Microsoft 문서는 Visual Studio 2026의 MSVC Build Tools 14.50에서도 여러 C++23 기능과 모듈 안정성 개선이 추가됐다고 설명한다.[136]

MSVC의 /std:c++23preview는 C++23 기능을 미리 사용할 수 있는 모드다. Microsoft 문서는 이 모드의 기능이 변경될 수 있고 릴리스 사이의 ABI 호환성이 보장되지 않을 수 있다고 명시한다.[137]

cl ^
    /std:c++23preview ^
    /permissive- ^
    main.cpp

/std:c++latest는 C++23에 한정되지 않고 다음 작업 초안의 실험적 기능까지 활성화할 수 있다.

Apple Clang

Apple Clang은 Apple이 Xcode와 macOS, iOS 등의 SDK에 맞춰 배포하는 Clang 기반 구현체다.

xcrun clang++ \
    -std=c++20 \
    main.cpp \
    -o application

Apple Clang은 LLVM Clang을 기반으로 하지만 버전 번호와 기능 지원 범위가 같은 번호의 일반 LLVM 릴리스와 일치하지 않을 수 있다.

Apple 플랫폼에서 실제 사용 가능한 C++ 기능은 다음 요소의 영향을 받는다.

  • Xcode 버전
  • Apple Clang 버전
  • libc++ 버전
  • macOS 또는 iOS SDK
  • 최소 배포 대상 운영체제
  • 운영체제가 제공하는 런타임 심볼

컴파일 시 헤더에 선언이 존재해도 오래된 운영체제에서 필요한 런타임 기능이 제공되지 않을 수 있다.

특수 목적 구현체

C++는 데스크톱 컴파일러 외에도 다양한 특수 환경에서 구현된다.

특수 목적 C++ 구현체
  • 가속기와 GPU
    • NVIDIA CUDA C++
    • AMD HIP 계열 도구
    • SYCL 구현체
  • 임베디드
    • Arm Compiler
    • IAR C/C++
    • Green Hills
    • Renesas 컴파일러
    • 마이크로컨트롤러 전용 구현체
  • 웹과 가상 기계
    • Emscripten
    • WebAssembly 대상 Clang
  • 게임 콘솔과 특수 플랫폼
    • 플랫폼 SDK 컴파일러
    • 전용 링커
    • 플랫폼별 C++ 런타임

특수 목적 구현체는 최신 표준 전체보다 특정 플랫폼의 성능, 코드 크기, 디버거와 SDK 통합을 우선할 수 있다.

CUDA C++의 다음 코드는 ISO C++ 확장을 사용한다.

__global__
void add(
    const float* left,
    const float* right,
    float* output
)
{
    int index =
        blockIdx.x *
        blockDim.x +
        threadIdx.x;

    output[index] =
        left[index] +
        right[index];
}

__global__, blockIdxthreadIdx는 ISO C++ 표준에 정의된 이름이 아니라 CUDA 구현체의 확장이다.

표준 라이브러리 구현체

C++ 표준 라이브러리는 표준이 요구하는 타입과 함수를 실제 코드로 제공한다.

대표적인 구현체는 다음과 같다.

  • GNU libstdc++
  • LLVM libc++
  • Microsoft C++ Standard Library

libstdc++

libstdc++는 GCC 프로젝트의 표준 C++ 라이브러리 구현체다.

#include <string>
#include <vector>

std::vector<std::string>
    languages {
        "C",
        "C++"
    };

GCC 도구 체계에서는 위 코드에 필요한 std::vectorstd::string 구현을 일반적으로 libstdc++가 제공한다.

libstdc++의 기능 지원은 GCC 핵심 언어 지원과 별도로 관리된다. GCC 프런트엔드가 특정 문법을 지원해도 해당 라이브러리 기능이 같은 GCC 릴리스에서 완성되지 않았을 수 있다.

libc++

libc++는 LLVM 프로젝트의 표준 C++ 라이브러리 구현체다. Clang과 함께 널리 사용되지만 Clang에만 종속된 것은 아니다.

clang++ \
    -std=c++23 \
    -stdlib=libc++ \
    main.cpp

libc++는 자체 ABI와 버전 정책을 가지며, 운영체제에 설치된 libc++ 버전이 컴파일러 버전과 반드시 같지는 않다.

Microsoft C++ Standard Library

Microsoft의 표준 라이브러리는 MSVC와 Windows 런타임 환경에 맞춰 제공된다.

#include <filesystem>
#include <vector>

std::filesystem::path
    path = "document.txt";

std::vector<int>
    values;

Microsoft 표준 라이브러리는 Windows ABI와 Visual Studio의 이진 호환성 정책을 고려해 발전한다.

컴파일러와 라이브러리의 조합

프런트엔드와 표준 라이브러리는 하나의 이름으로 함께 배포되는 경우가 많지만 개념적으로는 다른 구현 계층이다.

대표적인 컴파일러와 라이브러리 조합
  • GNU 도구 체계
    • GCC 프런트엔드
    • libstdc++
  • LLVM 도구 체계
    • Clang 프런트엔드
    • libc++
  • Linux의 Clang 구성
    • Clang 프런트엔드
    • libstdc++ 사용 가능
  • Windows의 clang-cl 구성
    • Clang 프런트엔드
    • Microsoft ABI
    • Microsoft C++ Standard Library
  • Apple 도구 체계
    • Apple Clang
    • Apple 배포 libc++

하나의 프로젝트에서 서로 다른 표준 라이브러리를 임의로 혼합해서는 안 된다. std::string, std::vector, 예외와 메모리 할당에 사용되는 ABI가 호환되지 않을 수 있다.

언어 모드

컴파일러는 특정 C++ 표준 개정판에 해당하는 언어 모드를 제공한다.

GCC와 Clang 계열에서는 일반적으로 -std= 옵션을 사용한다.

g++ -std=c++17 main.cpp
g++ -std=c++20 main.cpp
g++ -std=c++23 main.cpp

차기 표준 개발 모드에는 임시 이름이 사용될 수 있다.

g++ \
    -std=c++2c \
    main.cpp

c++2c는 C++26 개발 과정에서 사용되는 임시 명칭이다. 실험 기능의 문법과 의미는 이후 변경될 수 있다.

GNU 확장 모드는 다음과 같이 지정한다.

g++ \
    -std=gnu++23 \
    main.cpp

MSVC는 /std: 옵션을 사용한다.

/std:c++14
/std:c++17
/std:c++20
/std:c++23preview
/std:c++latest

표준 모드 옵션은 해당 버전의 기능을 사용할 수 있도록 요청하지만, 구현체가 그 표준의 모든 기능을 완전히 구현했음을 자동으로 보증하지는 않는다.

기본 언어 모드

-std/std 옵션을 지정하지 않으면 컴파일러가 자체 기본 언어 모드를 사용한다.

g++ main.cpp

기본 언어 모드는 컴파일러 버전이 바뀌면서 변경될 수 있다. 같은 소스와 빌드 스크립트라도 개발 환경에 설치된 컴파일러 버전에 따라 다른 표준 모드로 처리될 수 있다.

프로젝트는 재현 가능한 빌드를 위해 요구 언어 버전을 명시할 수 있다.

target_compile_features(
    application
    PRIVATE
        cxx_std_23
)

확장 모드를 비활성화할 수도 있다.

set_target_properties(
    application
    PROPERTIES
        CXX_STANDARD 23
        CXX_STANDARD_REQUIRED YES
        CXX_EXTENSIONS NO
)

__cplusplus

활성화된 C++ 표준 버전은 __cplusplus 매크로를 통해 확인할 수 있다.

#if __cplusplus >= 202302L

void use_cpp23();

#elif __cplusplus >= 202002L

void use_cpp20();

#elif __cplusplus >= 201703L

void use_cpp17();

#else

#error C++17 or later is required

#endif

대표적인 값은 다음과 같다.

언어 모드__cplusplus
C++98·C++03199711L
C++11201103L
C++14201402L
C++17201703L
C++20202002L
C++23202302L

C++23에서 값이 202302L인 것은 표준의 기술 작업이 2023년 2월에 완료된 사실과 연결된다.[138]

MSVC의 일부 구성에서는 정확한 __cplusplus 값을 사용하기 위해 /Zc:__cplusplus를 지정해야 할 수 있다.

cl ^
    /std:c++20 ^
    /Zc:__cplusplus ^
    main.cpp

기능 검사 매크로

언어 표준 전체가 아니라 개별 기능의 지원 여부를 확인하려면 기능 검사 매크로를 사용한다.

핵심 언어 기능

#if defined(__cpp_concepts)

template<class T>
concept Addable =
    requires(T value) {
        value + value;
    };

#endif

표준 라이브러리 기능

라이브러리 기능 검사 매크로는 <version> 헤더에서 확인할 수 있다.

#include <version>

#if defined(__cpp_lib_expected)

#include <expected>

#endif

매크로 값으로 구현된 개정 수준을 확인할 수 있다.

#if defined(__cpp_lib_format) && \
    __cpp_lib_format >= 202207L

void use_updated_format();

#endif

기능 검사 매크로는 단순히 __cplusplus만 검사하는 것보다 부분적으로 구현된 컴파일러와 표준 라이브러리에서 더 정확하다.

표준 기능 지원 확인 과정
  1. 필요한 기능이 포함된 표준 확인
  2. 관련 WG21 제안서와 표준 문구 확인
  3. 컴파일러 핵심 언어 지원표 확인
  4. 표준 라이브러리 지원표 확인
  5. 필요한 언어 모드 확인
  6. 기능 검사 매크로 확인
  7. 대상 구현체에서 최소 예제 컴파일
  8. CI에 지원 조건 고정

컴파일러 식별

구현체별 코드가 필요한 경우 컴파일러가 제공하는 매크로를 사용할 수 있다.

#if defined(__clang__)

#define PROJECT_COMPILER_CLANG 1

#elif defined(_MSC_VER)

#define PROJECT_COMPILER_MSVC 1

#elif defined(__GNUC__)

#define PROJECT_COMPILER_GCC 1

#else

#define PROJECT_COMPILER_UNKNOWN 1

#endif

Clang은 GCC 호환성을 위해 __GNUC__를 함께 정의하는 경우가 있으므로 __clang__ 검사를 먼저 해야 한다.

컴파일러 이름보다 필요한 기능 자체를 검사할 수 있다면 기능 검사 매크로를 우선하는 것이 좋다.

#if defined(__cpp_consteval)

consteval int answer()
{
    return 42;
}

#endif

표준 확장

컴파일러는 ISO C++에 포함되지 않은 기능을 확장으로 제공할 수 있다.

대표적인 확장은 다음과 같다.

  • GNU statement expression
  • 가변 길이 배열
  • 컴파일러별 속성
  • 인라인 어셈블리
  • SIMD 벡터 타입
  • 플랫폼별 호출 규약
  • CUDA 함수 한정자
  • Objective-C++ 문법
  • OpenMP 지시문
  • 심볼 가시성 속성
  • DLL 내보내기 속성

GNU statement expression은 다음과 같은 형태를 가진다.

#if defined(__GNUC__)

int value = ({
    int temporary = calculate();

    temporary * 2;
});

#endif

이 문법은 GNU 확장이며 ISO C++ 프로그램에서는 사용할 수 없다.

Windows DLL 내보내기에는 MSVC 확장이 사용될 수 있다.

#if defined(_MSC_VER)

__declspec(dllexport)
void exported_function();

#endif

GCC와 Clang에서는 심볼 가시성 속성을 사용할 수 있다.

#if defined(__GNUC__) || \
    defined(__clang__)

__attribute__((visibility("default")))
void exported_function();

#endif

표준 속성 문법 안에 구현체 이름공간을 사용할 수도 있다.

[[gnu::always_inline]]
inline void fast_function()
{
}

속성의 실제 의미와 지원 범위는 구현체 문서에서 확인해야 한다.

엄격한 표준 모드

GCC와 Clang에서는 표준에 없는 확장 사용을 진단하도록 설정할 수 있다.

g++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp

표준에서 진단이 요구되는 확장 사용을 오류로 처리할 수도 있다.

g++ \
    -std=c++23 \
    -pedantic-errors \
    main.cpp

MSVC에서는 /permissive-를 사용해 더 엄격한 표준 적합성 동작을 활성화한다.

cl ^
    /std:c++20 ^
    /permissive- ^
    /W4 ^
    main.cpp

엄격 모드를 사용해도 구현 정의 동작과 플랫폼 차이까지 사라지는 것은 아니다.

구현 적합성

표준 적합성은 구현체가 표준이 요구하는 프로그램을 올바르게 번역하고 실행한다는 의미다. 모든 구현체가 동일한 기계어, 메모리 사용량, 성능과 오류 메시지를 생성해야 한다는 뜻은 아니다.

C++ 표준은 프로그램 동작을 여러 범주로 구분한다.

C++ 동작의 표준상 분류
  • 정의된 동작
    • 표준이 프로그램 의미를 규정
  • 구현 정의 동작
    • 구현체가 선택
    • 선택한 내용을 문서화해야 함
  • 미지정 동작
    • 여러 허용 결과 가운데 하나를 선택
    • 선택을 문서화할 의무가 없음
  • 미정의 동작
    • 표준이 프로그램 의미를 규정하지 않음
  • 조건부 지원
    • 구현체가 지원 여부를 선택할 수 있음

구현 정의 동작

char의 부호 여부와 기본 타입의 크기처럼 구현체가 결정하고 문서화하는 동작이 있다.

char value = -1;

일반 char가 부호 있는 정수처럼 동작하는지 부호 없는 정수처럼 동작하는지는 구현체가 정할 수 있다.

static_assert(
    sizeof(long) >=
    sizeof(int)
);

long의 최소 관계는 규정되지만 정확한 크기는 플랫폼 데이터 모델에 따라 달라질 수 있다.

미지정 동작

표준이 여러 결과를 허용하지만 구현체가 선택을 문서화할 필요가 없는 경우다.

int first();
int second();

consume(
    first(),
    second()
);

각 함수 호출의 상대적인 평가 순서는 해당 표준 버전이 허용하는 범위에서 달라질 수 있다.

미지정 동작은 미정의 동작과 다르다. 결과는 반드시 표준이 허용한 범위 안에 있어야 한다.

미정의 동작

다음 코드는 널 포인터를 역참조한다.

int* pointer = nullptr;

int value = *pointer;

다음 코드는 배열 범위를 벗어난다.

int values[3] {
    1,
    2,
    3
};

int value = values[3];

이러한 프로그램에는 표준이 의미를 부여하지 않는다. 특정 구현체에서 예상한 값이 출력됐다고 해서 동작이 보장되는 것은 아니다.

컴파일러는 정의된 프로그램에는 미정의 동작이 없다고 가정해 최적화할 수 있다.

호스팅 구현과 독립 실행 구현

C++ 표준은 일반 운영체제 애플리케이션을 위한 호스팅 구현과, 완전한 운영체제 환경을 가정하지 않는 독립 실행 구현을 구분한다.

C++ 실행 환경
  • 호스팅 구현
    • 일반적인 main 진입점
    • 광범위한 표준 라이브러리
    • 파일과 스트림
    • 운영체제 서비스
    • 데스크톱·서버 애플리케이션
  • 독립 실행 구현
    • 구현체 정의 진입점
    • 제한된 표준 라이브러리
    • 운영체제 없이 실행 가능
    • 커널과 부트로더
    • 펌웨어
    • 마이크로컨트롤러

일반 호스팅 프로그램은 main()에서 시작한다.

#include <iostream>

int main()
{
    std::cout
        << "hosted"
        << '\n';
}

독립 실행 환경에서는 플랫폼이 정의한 별도의 진입점을 사용할 수 있다.

extern "C"
void reset_handler()
{
    initialize_hardware();

    while (true) {
        run_firmware_step();
    }
}

GCC와 Clang에서는 독립 실행 환경을 위한 옵션을 사용할 수 있다.

g++ \
    -std=c++23 \
    -ffreestanding \
    -fno-exceptions \
    -fno-rtti \
    -c firmware.cpp

이 옵션은 컴파일러 기능이며 ISO C++ 문법 자체는 아니다.

ABI

C++ 표준은 모든 플랫폼과 구현체가 공유하는 하나의 ABI를 규정하지 않는다.

ABI는 별도로 컴파일된 코드가 서로 호출되고 데이터를 교환하는 바이너리 규칙이다.

C++ ABI의 구성 요소
  • 함수 호출
    • 호출 규약
    • 매개변수 전달
    • 반환값 전달
  • 객체 표현
    • 클래스 배치
    • 멤버 정렬
    • 패딩
    • 가상 함수 테이블
    • 기반 클래스 배치
  • 런타임 표현
    • RTTI
    • 예외 처리
    • 스레드 지역 저장소
    • 정적 초기화
  • 링커 심볼
    • 이름 장식
    • 템플릿 심볼
    • 심볼 가시성

다음 클래스의 바이너리 배치는 ISO C++ 표준에 의해 하나의 형태로 고정되지 않는다.

class Interface {
public:
    virtual void run() = 0;
    virtual ~Interface() = default;
};

Unix 계열의 여러 C++ 구현은 Itanium C++ ABI 계열 규칙을 사용하고, Microsoft 도구 체계는 별도의 Microsoft C++ ABI를 사용한다.

같은 C++ 표준 버전을 사용하더라도 서로 다른 ABI를 사용하는 목적 파일을 직접 연결할 수 없을 수 있다.

표준 라이브러리 ABI

표준 라이브러리 타입을 공유 라이브러리나 플러그인 경계에 노출하면 표준 라이브러리 구현체의 ABI에 의존한다.

class Plugin {
public:
    virtual std::vector<
        std::string
    > names() const = 0;

    virtual ~Plugin() = default;
};

이 인터페이스를 바이너리 경계에 사용하면 다음 조건이 필요할 수 있다.

  • 같은 std::vector 구현
  • 호환되는 std::string ABI
  • 호환되는 할당자와 힙
  • 호환되는 예외 처리
  • 호환되는 컴파일러 옵션
  • 호환되는 디버그 반복자 설정

더 좁고 안정적인 경계가 필요하면 C ABI와 불투명 핸들을 사용할 수 있다.

extern "C" {

struct PluginHandle;

PluginHandle*
plugin_create();

void plugin_destroy(
    PluginHandle* plugin
);

std::size_t plugin_name(
    PluginHandle* plugin,
    char* output,
    std::size_t capacity
);

}

C ABI도 모든 플랫폼에서 완전히 동일한 것은 아니지만 C++ 클래스와 표준 컨테이너를 직접 노출하는 것보다 관리할 요소가 적다.

디버그 구성과 ABI

컴파일러와 표준 라이브러리는 디버그 설정에 따라 객체 표현과 검사 코드를 변경할 수 있다.

영향을 줄 수 있는 설정은 다음과 같다.

  • 디버그 및 릴리스 런타임
  • 반복자 디버깅
  • 표준 라이브러리 하드닝
  • 구조체 패킹
  • 예외 활성화
  • RTTI 활성화
  • sanitizer 삽입
  • 런타임 검사
  • 정적 또는 동적 런타임 연결

서로 다른 설정으로 컴파일된 라이브러리와 애플리케이션을 섞으면 ABI와 메모리 할당 충돌이 발생할 수 있다.

진단

표준은 잘못된 프로그램에 대한 진단이 필요한 경우를 규정하지만 오류 메시지의 형식과 문장은 규정하지 않는다.

int main()
{
    int value =
        "text";
}

이 프로그램은 유효한 정수 초기화가 아니므로 구현체는 진단해야 한다.

GCC, Clang과 MSVC는 각각 다른 형식의 오류 메시지, 오류 코드와 후보 함수 목록을 출력할 수 있다.

일부 표준 위반은 진단이 요구되지 않는다. 여러 번역 단위에 서로 다른 인라인 정의가 존재하는 ODR 위반처럼 링커가 자동으로 발견하기 어려운 문제가 이에 포함될 수 있다.

경고

컴파일러 경고는 표준 오류와 같지 않다. 표준이 허용하는 코드도 잠재적인 오류 가능성이 있으면 경고할 수 있다.

int convert(double value)
{
    return value;
}

double에서 int로 변환하는 것은 허용되지만 소수 부분이나 범위를 잃을 수 있다.

GCC와 Clang에서는 여러 경고를 활성화할 수 있다.

g++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    -Wconversion \
    -Wsign-conversion \
    main.cpp

MSVC에서는 경고 수준을 지정한다.

cl ^
    /std:c++20 ^
    /W4 ^
    /permissive- ^
    main.cpp

프로젝트 코드와 외부 라이브러리에는 서로 다른 경고 정책을 적용할 수 있다.

최적화

구현체는 프로그램의 관찰 가능한 동작을 유지하는 범위에서 소스 구조와 다른 기계어를 생성할 수 있다.

int calculate()
{
    int value = 10;
    int result =
        value * 2;

    return result;
}

컴파일러는 중간 변수를 모두 제거하고 상수 20을 직접 반환할 수 있다.

이를 설명하는 기본 원칙이 as-if 규칙이다. 구현체는 프로그램이 표준상 관찰할 수 있는 결과가 동일한 한 어떤 내부 변환도 수행할 수 있다.

최적화의 기본 관계
  1. C++ 소스의 의미 분석
  2. 관찰 가능한 동작 확인
  3. 불필요한 연산 제거
  4. 상수 전파와 인라인화
  5. 대상 아키텍처에 맞는 명령 선택
  6. 동일한 관찰 결과를 만드는 기계어 생성

대표적인 최적화는 다음과 같다.

  • 상수 전파
  • 죽은 코드 제거
  • 함수 인라인화
  • 루프 전개와 변환
  • 자동 벡터화
  • 공통 부분식 제거
  • 탈가상화
  • 별칭 분석
  • 링크 시간 최적화
  • 프로파일 기반 최적화

GCC와 Clang 계열의 일반적인 최적화 옵션은 다음과 같다.

-O0
-O1
-O2
-O3
-Os
-Oz
-Og

링크 시간 최적화는 여러 번역 단위를 함께 분석한다.

g++ \
    -std=c++23 \
    -O2 \
    -flto \
    main.cpp \
    renderer.cpp \
    -o application

높은 최적화 수준이 모든 프로그램에서 반드시 더 빠른 것은 아니다. 코드 크기, 캐시 사용, 컴파일 시간과 실제 데이터 분포를 측정해야 한다.

구현 한계

모든 구현체는 유한한 메모리와 내부 자료구조를 사용한다. 표준이 일부 최소 한계를 규정하더라도 실제 최대치는 구현체마다 다르다.

영향을 받을 수 있는 요소는 다음과 같다.

  • 템플릿 인스턴스화 깊이
  • 상수 평가 연산 수
  • 전처리기 중첩
  • 클래스 멤버 수
  • 함수 매개변수 수
  • 심볼 이름 길이
  • 목적 파일 크기
  • 디버그 정보 크기
  • 모듈 인터페이스 크기
template<int Value>
struct Recursive :
    Recursive<Value - 1> {
};

template<>
struct Recursive<0> {
};

지나치게 깊은 템플릿 재귀는 구현체의 인스턴스화 한계를 초과할 수 있다.

GCC와 Clang에서는 일부 한계를 옵션으로 조정할 수 있다.

g++ \
    -ftemplate-depth=2048 \
    main.cpp

이 옵션의 정확한 이름과 의미는 구현체 문서에 속한다.

구현체 버그

컴파일러와 표준 라이브러리도 소프트웨어이므로 표준과 다른 진단이나 잘못된 기계어를 생성하는 버그를 가질 수 있다.

특히 다음 영역은 구현이 복잡하다.

  • 템플릿 이름 탐색
  • 개념과 제약 조건
  • 상수 평가
  • 모듈
  • 코루틴
  • 예외 처리
  • 원자적 메모리 모델
  • 최적화와 별칭 분석
  • 디버그 정보
  • 최신 라이브러리 기능

구현체 버그가 의심되면 다음 과정을 사용할 수 있다.

컴파일러 문제 조사 과정
  1. 문제가 발생한 코드 확인
  2. 최소 재현 예제로 축소
  3. 최적화 수준과 언어 모드 비교
  4. 다른 컴파일러에서 결과 비교
  5. 공식 버그 추적기 검색
  6. 정확한 버전과 옵션 기록
  7. 제한된 우회 코드 작성
  8. 수정 버전 도입 후 우회 제거

컴파일러 버전에 따른 우회 코드는 영향을 받는 범위를 좁게 제한해야 한다.

#if defined(__GNUC__) && \
    !defined(__clang__) && \
    __GNUC__ < 15

void implementation()
{
    use_gcc_workaround();
}

#else

void implementation()
{
    use_standard_implementation();
}

#endif

정적 분석

정적 분석기는 프로그램을 실행하지 않고 소스와 중간 표현을 분석해 잠재적인 결함을 찾는다.

대표적인 도구는 다음과 같다.

  • Clang Static Analyzer
  • clang-tidy
  • GCC -fanalyzer
  • MSVC Code Analysis
  • PVS-Studio
  • Coverity
  • CodeQL
  • cppcheck
clang-tidy \
    source.cpp \
    -- \
    -std=c++23

정적 분석은 다음 문제를 탐지할 수 있다.

  • 널 포인터 사용
  • 초기화되지 않은 값
  • 자원 누수
  • 수명 오류
  • 범위 초과 가능성
  • 잘못된 이동
  • 잠금 순서 문제
  • 위험한 타입 변환
  • API 사용 오류

정적 분석 경고가 항상 실제 오류인 것은 아니므로 프로그램의 불변 조건과 함께 검토해야 한다.

런타임 검사기

sanitizer는 실행 중 메모리와 동시성 오류를 검사하는 도구 체계 기능이다.

AddressSanitizer와 UndefinedBehaviorSanitizer를 활성화할 수 있다.

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=address,undefined \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

데이터 경쟁은 ThreadSanitizer로 검사할 수 있다.

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=thread \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

대표적인 검사기는 다음과 같다.

  • AddressSanitizer
  • UndefinedBehaviorSanitizer
  • ThreadSanitizer
  • MemorySanitizer
  • LeakSanitizer

이 기능들은 ISO C++ 표준 라이브러리가 아니라 컴파일러와 런타임 구현체의 도구다.

여러 구현체를 이용한 검증

한 컴파일러만 사용하면 해당 구현체의 확장이나 버그에 우연히 의존할 수 있다.

Linux에서는 GCC와 Clang을 함께 검사할 수 있다.

g++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp
clang++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp

Windows에서는 MSVC나 clang-cl을 추가할 수 있다.

cl ^
    /std:c++20 ^
    /permissive- ^
    /W4 ^
    main.cpp

여러 구현체의 결과가 다르면 다음 요소를 확인해야 한다.

  • 서로 다른 표준 모드
  • 컴파일러 확장
  • 구현 정의 동작
  • 미정의 동작
  • 결함 보고서 반영 차이
  • 표준 라이브러리 지원 차이
  • 플랫폼 ABI
  • 구현체 버그

지속적 통합

공식적으로 지원하는 구현체와 플랫폼을 CI 빌드 행렬에 포함할 수 있다.

C++ 프로젝트의 빌드 행렬 예시
  • Linux
    • GCC와 libstdc++
    • Clang과 libstdc++
    • Clang과 libc++
  • Windows
    • MSVC
    • clang-cl과 Microsoft 표준 라이브러리
  • macOS
    • Apple Clang과 libc++
  • 추가 검사
    • AddressSanitizer
    • UndefinedBehaviorSanitizer
    • ThreadSanitizer
    • 정적 분석
    • 릴리스 최적화 빌드

CI에서는 다음 작업을 수행할 수 있다.

  • 디버그 빌드
  • 릴리스 빌드
  • 단위 테스트
  • 통합 테스트
  • 정적 분석
  • sanitizer 실행
  • 여러 표준 모드 검사
  • 설치 후 소비자 프로젝트 빌드
  • ABI 비교
  • 문서 예제 컴파일

공식 지원 범위와 CI에서 실제로 검사하는 범위가 일치해야 한다.

최소 지원 구현체

프로젝트는 필요한 기능을 기준으로 최소 컴파일러 버전을 지정할 수 있다.

GCC 14 이상
Clang 18 이상
MSVC 19.40 이상

이 버전은 예시이며 실제 프로젝트에서는 필요한 핵심 언어와 라이브러리 기능을 기준으로 정해야 한다.

최소 지원 버전을 결정할 때에는 다음 요소를 고려한다.

  • 필요한 C++ 언어 버전
  • 필요한 표준 라이브러리 기능
  • 콘솔과 임베디드 SDK
  • 배포 운영체제
  • 패키지 관리자가 제공하는 버전
  • 보안 업데이트
  • ABI 호환성
  • IDE와 디버거 지원
  • 빌드 시간
  • 장기 유지보수 기간

버전 매크로로 명시적인 오류를 발생시킬 수 있다.

#if defined(__GNUC__) && \
    !defined(__clang__)

#if __GNUC__ < 14
#error GCC 14 or later is required
#endif

#endif

다만 배포판이 기능을 백포트할 수 있으므로 가능한 경우 기능 검사 매크로를 사용하는 편이 더 정확하다.

표준 기능의 조건부 사용

프로젝트의 최소 기준은 C++20으로 유지하면서 일부 C++23 라이브러리 기능을 선택적으로 사용할 수 있다.

#include <version>

#if defined(__cpp_lib_print)

#include <print>

void log_message(
    std::string_view message
)
{
    std::println(
        "{}",
        message
    );
}

#else

#include <iostream>

void log_message(
    std::string_view message
)
{
    std::cout
        << message
        << '\n';
}

#endif

std::expected도 기능 검사 후 사용할 수 있다.

#include <version>

#if defined(__cpp_lib_expected)

#include <expected>

template<class T>
using Result =
    std::expected<T, Error>;

#else

template<class T>
class Result {
    // 호환 구현
};

#endif

이 방식은 단순히 컴파일러 이름과 버전을 추측하는 것보다 실제 기능 제공 여부를 직접 확인한다.

표준과 이식성

ISO C++ 문법만 사용한다고 해서 모든 플랫폼에서 완전히 동일한 프로그램이 되는 것은 아니다.

다음 요소는 구현체와 플랫폼에 따라 달라질 수 있다.

  • 기본 정수 타입의 크기
  • 일반 char의 부호
  • 포인터 크기
  • 구조체 정렬과 패딩
  • 바이트 순서
  • 파일 경로 표현
  • 부동소수점 특성
  • 스레드 구현
  • 시간 정밀도
  • 동적 라이브러리 형식
  • 운영체제 API
  • ABI

정확한 폭이 필요한 정수에는 <cstdint> 타입을 사용할 수 있다.

#include <cstdint>

std::uint32_t identifier = 0;

다만 해당 폭의 정수 타입을 구현체가 제공할 수 있을 때에만 정확한 폭 타입이 존재한다.

특정 플랫폼만 지원한다면 정적 단정으로 요구 조건을 명시할 수 있다.

static_assert(
    sizeof(void*) == 8,
    "64비트 포인터 환경이 필요하다."
);

이는 C++ 전체의 보장이 아니라 해당 프로젝트의 플랫폼 요구 사항이다.

구현체 종속 코드의 격리

운영체제와 컴파일러 확장은 공통 인터페이스 뒤에 숨길 수 있다.

class DynamicLibrary {
public:
    explicit DynamicLibrary(
        const Path& path
    );

    void* symbol(
        const char* name
    );

    ~DynamicLibrary();
};

Windows 구현은 Win32 API를 사용할 수 있다.

#if defined(_WIN32)

class DynamicLibrary::Impl {
    HMODULE module =
        nullptr;
};

#endif

Unix 계열 구현은 dlopen()dlsym()을 사용할 수 있다.

#if defined(__unix__) || \
    defined(__APPLE__)

class DynamicLibrary::Impl {
    void* handle =
        nullptr;
};

#endif

외부 코드는 플랫폼별 API를 직접 다루지 않는다.

DynamicLibrary library(
    plugin_path
);

void* entry =
    library.symbol(
        "plugin_initialize"
    );

프로젝트의 C++ 방언

대형 프로젝트는 ISO C++ 위에 추가적인 사용 규칙을 정의할 수 있다.

예를 들면 다음과 같다.

  • C++23 사용
  • GNU 확장 금지
  • 예외 사용 허용 또는 금지
  • RTTI 사용 허용 또는 금지
  • 원시 new 사용 제한
  • 특정 경고를 오류로 처리
  • 표준 라이브러리 ABI 경계 제한
  • 모듈 또는 헤더 사용 규칙
  • 정적 분석 필수
  • sanitizer 빌드 필수
프로젝트 C++ 규칙 예시
  • 언어 설정
    • C++23
    • 표준 확장 비활성화
    • C++23 기능은 지원표 확인
  • 런타임 설정
    • 예외 활성화
    • RTTI 활성화
    • 동적 런타임 사용
  • 품질 검사
    • 높은 경고 수준
    • 정적 분석
    • sanitizer
    • 여러 컴파일러 CI
  • ABI 정책
    • 공개 C ABI
    • 표준 컨테이너 외부 노출 제한
    • 지원 도구 체계 버전 고정

이러한 규칙은 ISO C++ 표준 자체가 아니라 프로젝트가 선택한 C++ 사용 범위다.

표준 자료의 확인 순서

C++ 기능을 조사할 때에는 자료의 성격과 최신성을 구분해야 한다.

C++ 기술 자료의 확인 순서
  • 규범 자료
    • 발행된 ISO C++ 표준
    • 채택된 결함 수정
  • 위원회 자료
    • 최신 작업 초안
    • WG21 제안서
    • 핵심 언어 이슈
    • 라이브러리 이슈
  • 구현체 공식 자료
    • GCC 기능 지원표
    • Clang 기능 지원표
    • Microsoft 적합성 표
    • 표준 라이브러리 상태표
  • 참고 자료
    • cppreference
    • 서적
    • 강연
    • 블로그
    • 질의응답

cppreference는 표준 기능을 빠르게 조사하기에 유용하지만 ISO 표준 그 자체는 아니다. 구현체 지원표도 실제 배포 버전이나 버그 수정과 시차가 있을 수 있으므로 중요한 기능은 대상 환경에서 직접 컴파일해야 한다.

종합 예제

다음 설정은 C++20을 최소 기준으로 하고, 구현체가 지원하는 C++23 라이브러리 기능을 선택적으로 사용한다.

#pragma once

#include <version>

#if __cplusplus < 202002L
#error C++20 or later is required
#endif

#if defined(__cpp_lib_expected)
#define PROJECT_HAS_EXPECTED 1
#else
#define PROJECT_HAS_EXPECTED 0
#endif

#if defined(__cpp_lib_print)
#define PROJECT_HAS_PRINT 1
#else
#define PROJECT_HAS_PRINT 0
#endif

#if defined(__clang__)
#define PROJECT_COMPILER_CLANG 1
#elif defined(_MSC_VER)
#define PROJECT_COMPILER_MSVC 1
#elif defined(__GNUC__)
#define PROJECT_COMPILER_GCC 1
#else
#define PROJECT_COMPILER_UNKNOWN 1
#endif

지원되는 환경에서는 std::expected를 사용한다.

#if PROJECT_HAS_EXPECTED

#include <expected>

template<class T>
using Result =
    std::expected<T, Error>;

#else

#include <variant>

template<class T>
class Result {
    std::variant<T, Error>
        storage_;

public:
    Result(T value)
        : storage_(
              std::move(value)
          )
    {
    }

    Result(Error error)
        : storage_(
              std::move(error)
          )
    {
    }

    bool has_value() const noexcept
    {
        return std::holds_alternative<T>(
            storage_
        );
    }
};

#endif

출력 기능도 지원 여부에 따라 분리한다.

#if PROJECT_HAS_PRINT

#include <print>

template<class... Arguments>
void log(
    std::format_string<
        Arguments...
    > format,
    Arguments&&... arguments
)
{
    std::println(
        format,
        std::forward<Arguments>(
            arguments
        )...
    );
}

#else

#include <iostream>

template<class Value>
void log_value(
    const Value& value
)
{
    std::cout
        << value
        << '\n';
}

#endif

CMake에서는 최소 표준과 확장 정책을 명시한다.

cmake_minimum_required(
    VERSION 3.25
)

project(
    StandardExample
    LANGUAGES CXX
)

add_executable(
    application
    src/main.cpp
)

target_compile_features(
    application
    PRIVATE
        cxx_std_20
)

set_target_properties(
    application
    PROPERTIES
        CXX_EXTENSIONS NO
)

컴파일러별 경고 옵션을 분리할 수 있다.

if(MSVC)
    target_compile_options(
        application
        PRIVATE
            /W4
            /permissive-
            /Zc:__cplusplus
    )
else()
    target_compile_options(
        application
        PRIVATE
            -Wall
            -Wextra
            -Wpedantic
            -Wconversion
    )
endif()

이 구성에서 C++20은 모든 지원 환경이 제공해야 하는 최소 기반이다. C++23 기능은 단순한 컴파일러 버전 추측이 아니라 기능 검사 매크로를 통해 선택한다. 구현체별 옵션과 확장은 빌드 설정에 격리하며, 프로그램의 핵심 코드는 공통된 표준 인터페이스를 사용한다.

표준과 구현체의 관계 정리

C++ 표준은 프로그램의 의미를 규정하지만 하나의 특정 구현 방법을 강제하지 않는다. 구현체는 표준이 허용하는 범위에서 다음 요소를 결정한다.

  • 대상 프로세서
  • 목적 파일 형식
  • ABI
  • 이름 장식
  • 클래스와 가상 함수의 바이너리 표현
  • 예외 처리 방식
  • 표준 라이브러리 내부 구현
  • 최적화 전략
  • 진단 메시지
  • 구현 정의 동작
  • 확장 기능

컴파일러 프런트엔드는 핵심 언어를 구현하고, 표준 라이브러리 구현체는 컨테이너, 알고리즘, 입출력, 동시성과 같은 라이브러리 기능을 제공한다. 하나의 C++ 버전 지원 여부는 두 영역을 나눠 확인해야 한다.

현재 발행된 국제 표준은 C++23이며 공식 문서 번호는 ISO/IEC 14882:2024다. C++26은 작업 초안과 제안서를 통해 개발되고 있으며 아직 정식 국제 표준이 아니다.

이식 가능한 C++ 프로그램을 만들려면 표준 문법만 사용하는 것에 그치지 않고 구현 정의 동작, 플랫폼 ABI, 표준 라이브러리 지원, 컴파일러 버전과 빌드 옵션을 함께 관리해야 한다. 기능 검사 매크로, 여러 구현체를 사용하는 CI, 정적 분석과 런타임 검사기를 결합하면 표준과 실제 구현 사이의 차이를 체계적으로 다룰 수 있다.

개발 도구와 생태계

C++ 개발 생태계는 컴파일러만으로 이루어지지 않는다. 실제 프로젝트에서는 빌드 시스템, 의존성 관리자, 통합 개발 환경, 디버거, 프로파일러, 테스트 프레임워크, 정적 분석기, 문서 생성기와 지속적 통합 서비스가 함께 사용된다. C++ 표준은 이러한 개발 도구의 종류나 사용법을 직접 규정하지 않으므로 프로젝트는 대상 플랫폼과 배포 정책에 맞는 도구 체계를 별도로 구성해야 한다.

C++는 운영체제, 컴파일러와 ABI가 다양한 환경에서 사용되기 때문에 하나의 공식 패키지 관리자나 단일한 표준 빌드 시스템이 모든 프로젝트를 지배하지 않는다. 같은 소스 코드라도 GCC와 Clang, Microsoft Visual C++, libstdc++와 libc++, Windows와 Linux처럼 서로 다른 조합으로 빌드할 수 있다. 이러한 유연성은 넓은 플랫폼 지원을 가능하게 하지만 빌드 설정, 바이너리 호환성, 외부 라이브러리 버전과 배포 방식을 프로젝트가 직접 관리해야 한다는 의미이기도 하다.

C++ 개발 생태계
  • C++ 개발 환경
    • 언어 구현
      • 컴파일러
      • 표준 라이브러리
      • 링커
      • 플랫폼 SDK
    • 빌드
      • 빌드 구성 도구
      • 실제 빌드 실행기
      • 프로젝트 생성기
      • 분산 빌드와 캐시
    • 의존성 관리
      • 패키지 관리자
      • 운영체제 패키지
      • 소스 포함
      • 사내 바이너리 저장소
    • 개발 환경
      • 통합 개발 환경
      • 코드 편집기
      • 언어 서버
      • 코드 탐색 도구
    • 품질 검증
      • 테스트 프레임워크
      • 정적 분석
      • sanitizer
      • 퍼징
      • 코드 품질 검사
    • 실행 분석
      • 디버거
      • 프로파일러
      • 메모리 분석기
      • 추적 도구
    • 배포와 협업
      • 지속적 통합
      • 패키징
      • 문서 생성
      • 코드 리뷰
      • 버전 관리

도구 체계

C++에서 도구 체계는 소스 코드를 번역하고 실행 가능한 프로그램이나 라이브러리를 만드는 데 필요한 도구의 조합을 뜻한다. 일반적으로 컴파일러, 표준 라이브러리, 어셈블러, 링커, 디버거와 운영체제 SDK가 포함된다.

일반적인 C++ 도구 체계
  • 도구 체계
    • C++ 컴파일러
      • GCC
      • Clang
      • Microsoft Visual C++
      • 플랫폼 전용 컴파일러
    • 표준 라이브러리
      • libstdc++
      • libc++
      • Microsoft C++ Standard Library
    • 바이너리 도구
      • 어셈블러
      • 링커
      • 아카이버
      • 심볼 검사기
    • 디버거
      • GDB
      • LLDB
      • Visual Studio Debugger
    • 플랫폼 구성
      • 시스템 헤더
      • 운영체제 라이브러리
      • C 런타임
      • 플랫폼 SDK

Linux에서 GCC 기반 프로그램을 빌드하는 가장 단순한 명령은 다음과 같다.

g++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp \
    -o application

Clang을 사용할 수도 있다.

clang++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    main.cpp \
    -o application

Windows의 MSVC 개발자 명령 프롬프트에서는 다음과 같이 빌드할 수 있다.

cl ^
    /std:c++20 ^
    /EHsc ^
    /permissive- ^
    /W4 ^
    main.cpp

실제 프로젝트는 소스 파일과 라이브러리 수가 많고 플랫폼별 옵션이 다르므로 컴파일러 명령을 직접 반복하기보다 빌드 시스템을 사용한다.

빌드 시스템

빌드 시스템은 어떤 소스 파일을 어떤 옵션으로 컴파일하고, 생성된 목적 파일과 라이브러리를 어떤 순서로 연결할지를 관리한다. 파일 변경 시 다시 빌드해야 하는 대상도 계산한다.

빌드 과정은 일반적으로 구성 단계와 실행 단계로 나뉜다.

C++ 프로젝트의 빌드 과정
  1. 프로젝트와 도구 체계 선택
  2. 빌드 옵션과 의존성 구성
  3. 소스 파일과 대상 관계 분석
  4. 필요한 파일 컴파일
  5. 라이브러리와 실행 파일 링킹
  6. 테스트 실행
  7. 설치 또는 패키징

C++에서 널리 사용되는 빌드 관련 도구는 다음과 같다.

도구성격
CMake여러 빌드 시스템의 프로젝트 파일을 생성하는 빌드 구성 도구
Meson선언적인 프로젝트 구성과 빠른 빌드를 지향하는 빌드 시스템
Ninja빌드 그래프를 빠르게 실행하는 저수준 빌드 도구
GNU Make규칙과 파일 시각을 기반으로 명령을 실행하는 전통적 빌드 도구
MSBuildVisual Studio와 Microsoft 도구 체계의 빌드 시스템
Bazel재현성과 대규모 다중 언어 빌드를 지향하는 빌드 시스템
xmakeLua 기반 프로젝트 구성과 패키지 기능을 제공하는 빌드 도구
PremakeLua 스크립트에서 IDE와 빌드 프로젝트를 생성하는 도구

CMake

CMake는 C++ 프로젝트에서 널리 사용되는 빌드 구성 도구다. CMake 자체가 모든 소스 파일을 직접 컴파일하는 것은 아니며, Ninja, Make, Visual Studio나 Xcode 같은 실제 빌드 시스템을 위한 설정을 생성한다. CMake는 프로젝트를 실행 파일과 라이브러리 같은 논리적 대상으로 구성하고 대상 사이의 의존 관계를 표현한다.[139][140]

가장 기본적인 CMake 프로젝트는 다음과 같다.

cmake_minimum_required(
    VERSION 3.25
)

project(
    Example
    LANGUAGES CXX
)

add_executable(
    application
    src/main.cpp
)

target_compile_features(
    application
    PRIVATE
        cxx_std_23
)

빌드 디렉터리를 소스 디렉터리와 분리할 수 있다.

cmake \
    -S . \
    -B build \
    -G Ninja
cmake \
    --build build

CMake 프로젝트는 전역 옵션보다 대상 중심으로 작성하는 것이 일반적이다.

add_library(
    geometry
    STATIC
        src/Vector.cpp
        src/Matrix.cpp
)

target_include_directories(
    geometry
    PUBLIC
        include
)

target_compile_features(
    geometry
    PUBLIC
        cxx_std_20
)

target_link_libraries(
    application
    PRIVATE
        geometry
)

PUBLIC, PRIVATEINTERFACE는 사용 요구 사항이 현재 대상과 소비 대상에 어떻게 적용되는지를 나타낸다.

CMake 대상의 사용 요구 사항
  • PRIVATE
    • 현재 대상에만 적용
  • INTERFACE
    • 현재 대상을 사용하는 소비자에게 적용
  • PUBLIC
    • 현재 대상에 적용
    • 소비자에게도 전파

CMake는 여러 생성기를 지원하며 설치된 환경에서 사용 가능한 생성기는 cmake --help로 확인할 수 있다.[141]

Meson

Meson은 간결한 선언적 문법과 빠른 증분 빌드를 목표로 하는 다중 플랫폼 빌드 시스템이다. Linux, macOS와 Windows를 지원하며 C와 C++를 포함한 여러 언어를 처리한다.[142]

project(
  'example',
  'cpp',
  default_options: [
    'cpp_std=c++23',
    'warning_level=3'
  ]
)

application = executable(
  'application',
  'src/main.cpp'
)

test(
  'application-test',
  application
)

구성과 빌드는 다음과 같이 수행한다.

meson setup build
meson compile \
    -C build
meson test \
    -C build

Meson의 사용자 설명서는 빌드 대상, 의존성, 교차 컴파일, 설치와 Visual Studio 연동 등의 기능을 설명한다.[143]

Ninja

Ninja는 사람이 직접 복잡한 프로젝트를 작성하기보다는 CMake와 Meson 같은 상위 도구가 생성한 빌드 그래프를 빠르게 실행하도록 설계된 빌드 도구다.

rule compile
  command = c++ -std=c++23 -c $in -o $out

rule link
  command = c++ $in -o $out

build main.o: compile main.cpp
build application: link main.o

소규모 프로젝트에서는 직접 작성할 수 있지만 대형 프로젝트에서는 일반적으로 상위 빌드 구성 도구가 build.ninja를 생성한다.

GNU Make

Make는 대상, 의존 파일과 실행 명령으로 빌드를 정의한다.

CXX := c++
CXXFLAGS := -std=c++23 -Wall -Wextra -Wpedantic

application: main.o calculator.o
	$(CXX) $^ -o $@

main.o: main.cpp calculator.hpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

calculator.o: calculator.cpp calculator.hpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

clean:
	rm -f application main.o calculator.o

Make는 Unix 계열에서 오랫동안 사용됐으며 단순한 프로젝트와 기존 코드베이스에서 여전히 사용된다. 복잡한 다중 플랫폼 구성에서는 조건문과 플랫폼별 규칙이 크게 늘어날 수 있다.

빌드 구성

하나의 프로젝트는 여러 빌드 구성을 가질 수 있다.

일반적인 빌드 구성
  • Debug
    • 최적화 최소화
    • 디버그 심볼
    • 단정과 검사 활성화
    • 개발 중 오류 추적
  • Release
    • 최적화 활성화
    • 배포용 바이너리
    • 단정 일부 비활성화
    • 코드 크기와 성능 조정
  • RelWithDebInfo
    • 최적화
    • 디버그 심볼 유지
  • Sanitizer
    • 메모리 검사
    • 미정의 동작 검사
    • 데이터 경쟁 검사
  • Coverage
    • 코드 계측
    • 테스트 실행 범위 측정

CMake에서 빌드 유형을 지정할 수 있다.

cmake \
    -S . \
    -B build/debug \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Debug
cmake \
    -S . \
    -B build/release \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Release

Visual Studio처럼 여러 구성을 하나의 프로젝트에 포함하는 생성기에서는 빌드할 때 구성을 지정할 수 있다.

cmake \
    --build build \
    --config Release

빌드 캐시와 분산 빌드

대형 C++ 프로젝트는 헤더 분석과 템플릿 인스턴스화로 인해 전체 빌드 시간이 길어질 수 있다. 컴파일 결과를 캐시하거나 여러 컴퓨터에 작업을 분산하는 도구를 사용할 수 있다.

대표적인 도구는 다음과 같다.

  • ccache
  • sccache
  • distcc
  • Incredibuild
  • FASTBuild
  • Bazel 원격 캐시
  • 분산 실행 서비스
컴파일 캐시의 일반 동작
  1. 컴파일 명령과 입력 파일 분석
  2. 동일한 입력의 기존 결과 검색
  3. 캐시 적중 시 목적 파일 재사용
  4. 캐시 미적중 시 실제 컴파일
  5. 새 결과를 캐시에 저장

캐시 키에는 일반적으로 소스 내용, 포함 파일, 컴파일러 버전과 옵션이 반영된다. 서로 다른 ABI 설정이나 매크로 구성을 같은 결과로 잘못 취급하면 올바르지 않은 바이너리가 만들어질 수 있다.

의존성 관리

C++ 라이브러리는 소스 코드, 헤더, 정적 라이브러리와 공유 라이브러리의 형태로 배포될 수 있다. 같은 라이브러리도 컴파일러, 표준 라이브러리, 아키텍처와 빌드 옵션에 따라 서로 다른 바이너리가 필요할 수 있다.

C++ 외부 의존성의 형태
  • 헤더 전용 라이브러리
    • 헤더를 포함해 사용자 프로젝트에서 직접 인스턴스화
  • 소스 라이브러리
    • 프로젝트가 소스를 함께 컴파일
    • 필요한 옵션을 프로젝트가 통제
  • 정적 라이브러리
    • .a
    • .lib
    • 최종 바이너리에 코드 포함
  • 공유 라이브러리
    • .so
    • .dll
    • .dylib
    • 실행 시 별도 로드
  • 패키지
    • 헤더
    • 바이너리
    • 라이선스
    • 빌드 정보
    • 의존성 메타데이터

C++ 의존성 관리 방법은 다음과 같다.

  • 운영체제 패키지 관리자 사용
  • Conan이나 vcpkg 사용
  • Git 서브모듈
  • CMake FetchContent
  • 소스 트리 직접 포함
  • 미리 빌드된 SDK
  • 사내 아티팩트 저장소
  • 단일 헤더 배포
  • 공급업체 전용 패키지

Conan

Conan은 C와 C++를 위한 오픈 소스 다중 플랫폼 패키지 관리자다. 컴파일러, 운영체제, 아키텍처와 빌드 옵션을 패키지 설정에 포함하며 CMake, Meson과 여러 빌드 시스템에 연동할 수 있다.[144]

의존성을 선언하는 기본 예는 다음과 같다.

[requires]
fmt/11.0.2

[generators]
CMakeDeps
CMakeToolchain

패키지를 설치할 수 있다.

conan install . \
    --output-folder=build \
    --build=missing

CMake 도구 체인 파일을 사용해 프로젝트를 구성한다.

cmake \
    -S . \
    -B build \
    -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake

Conan은 패키지를 하나의 중앙 저장소에만 한정하지 않는 분산 모델을 제공하며 사내 원격 저장소도 사용할 수 있다.[145]

vcpkg

vcpkg는 Microsoft와 C++ 커뮤니티가 관리하는 오픈 소스 다중 플랫폼 C와 C++ 패키지 관리자다. Windows, macOS와 Linux에서 사용할 수 있으며 CMake 프로젝트와 긴밀하게 연동된다.[146]

매니페스트는 프로젝트가 요구하는 라이브러리를 선언한다.

{
  "name": "example",
  "version-string": "1.0.0",
  "dependencies": [
    "fmt",
    "sdl3"
  ]
}

vcpkg 도구 체인을 CMake에 전달할 수 있다.

cmake \
    -S . \
    -B build \
    -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"

vcpkg는 라이브러리 소스를 대상 환경에 맞춰 빌드하거나 바이너리 캐시에서 재사용할 수 있다.

운영체제 패키지 관리자

Linux 배포판에서는 시스템 패키지 관리자를 통해 개발 헤더와 라이브러리를 설치할 수 있다.

sudo apt install \
    libfmt-dev \
    libsdl2-dev

Fedora 계열에서는 다음과 같은 명령을 사용할 수 있다.

sudo dnf install \
    fmt-devel \
    SDL2-devel

시스템 패키지는 운영체제와 보안 업데이트 체계에 통합된다는 장점이 있다. 반면 배포판마다 제공 버전과 패키지 이름이 다르고, 하나의 시스템에서 여러 버전을 병행하기 어려울 수 있다.

FetchContent

CMake의 FetchContent를 사용해 구성 단계에서 외부 소스를 가져올 수 있다.

include(FetchContent)

FetchContent_Declare(
    fmt
    GIT_REPOSITORY
        https://github.com/fmtlib/fmt.git
    GIT_TAG
        11.0.2
)

FetchContent_MakeAvailable(
    fmt
)

target_link_libraries(
    application
    PRIVATE
        fmt::fmt
)

원격 저장소의 이동하는 브랜치보다 특정 태그나 커밋을 고정해야 재현 가능한 빌드에 유리하다.

의존성 잠금과 재현성

외부 패키지의 최신 버전을 매번 자동으로 선택하면 같은 소스가 날짜에 따라 다른 결과로 빌드될 수 있다.

재현 가능한 의존성 관리
  1. 직접 의존성 선언
  2. 정확한 버전 또는 버전 범위 결정
  3. 전이 의존성 해석
  4. 잠금 정보 또는 기준 버전 저장
  5. CI에서 동일한 패키지 구성 사용
  6. 업데이트를 별도 변경으로 검토

재현성을 높이기 위해 다음 정보를 관리할 수 있다.

  • 패키지 버전
  • 소스 커밋
  • 패치
  • 컴파일러 버전
  • 표준 라이브러리
  • 아키텍처
  • 런타임 종류
  • 빌드 옵션
  • 정적 또는 공유 연결
  • 패키지 저장소
  • 바이너리 캐시 키

C++ 패키지는 소스 버전만 같아도 ABI가 다를 수 있으므로 빌드 설정까지 함께 식별해야 한다.

통합 개발 환경

통합 개발 환경은 코드 편집, 빌드 구성, 코드 탐색, 디버깅, 테스트와 버전 관리 기능을 하나의 인터페이스에 제공한다.

대표적인 C++ 개발 환경은 다음과 같다.

도구주요 환경
Visual StudioWindows와 Microsoft 도구 체계
CLionWindows, Linux와 macOS의 다중 도구 체계
XcodemacOS와 Apple 플랫폼
Qt CreatorQt와 다중 플랫폼 C++ 개발
Eclipse CDTEclipse 기반 C와 C++ 개발
Code::Blocks경량 다중 플랫폼 C와 C++ IDE
Visual Studio Code확장을 통해 C++ 개발 기능을 구성하는 편집기
Vim·Neovim언어 서버와 플러그인을 조합하는 편집기
EmacsLSP와 빌드·디버깅 패키지를 조합하는 편집기

Visual Studio

Visual Studio는 MSVC 컴파일러, Microsoft C++ Standard Library, MSBuild와 Windows SDK를 통합한다. 프로젝트 생성, IntelliSense, 디버거, 프로파일러, 테스트 탐색기와 정적 분석 기능을 제공한다.

Visual Studio 프로젝트는 .sln.vcxproj를 사용할 수 있으며, CMake 프로젝트를 직접 열 수도 있다.

CLion

CLion은 Windows, macOS와 Linux에서 사용할 수 있는 C와 C++ IDE다. GCC, Clang, clang-cl, Microsoft Visual C++, IAR와 사용자 정의 도구 체계를 구성할 수 있다.[147]

CLion에서 말하는 도구 체계는 일반적으로 다음 요소를 포함한다.

CLion 도구 체계 구성
  • CLion Toolchain
    • C 컴파일러
    • C++ 컴파일러
    • 디버거
    • CMake 또는 빌드 도구
    • 환경 변수와 실행 환경

CLion은 CMake, Makefile, Gradle 기반 네이티브 프로젝트와 compilation database 등을 지원하며 디버거와 테스트 프레임워크를 IDE에 연결한다.

Visual Studio Code

Visual Studio Code는 전용 C++ IDE라기보다 확장 가능한 코드 편집기다. C++ 확장이나 clangd를 설치하고 컴파일러, 빌드 시스템과 디버거를 별도로 구성한다.

일반적인 구성은 다음과 같다.

Visual Studio Code 기반 C++ 환경
  • Visual Studio Code
    • C++ 언어 확장 또는 clangd
    • CMake Tools
    • GDB·LLDB·MSVC 디버거 연결
    • 테스트 확장
    • 프로젝트별 작업과 실행 설정

편집기가 컴파일러를 내장하는 것은 아니므로 실제 GCC, Clang이나 MSVC 도구 체계를 설치해야 한다.

언어 서버와 코드 인덱싱

C++ IDE는 프로젝트 전체의 선언과 정의를 분석해 자동 완성, 오류 표시, 이름 변경과 코드 탐색을 제공한다.

대표적인 언어 분석 도구는 다음과 같다.

  • clangd
  • Microsoft IntelliSense
  • JetBrains C++ 분석 엔진
  • ccls
  • Eclipse CDT 인덱서

C++ 분석기는 실제 컴파일 명령을 알아야 정확한 결과를 제공할 수 있다.

[
  {
    "directory": "/project/build",
    "command": "clang++ -std=c++23 -I../include -c ../src/main.cpp",
    "file": "../src/main.cpp"
  }
]

CMake는 compilation database를 생성할 수 있다.

cmake \
    -S . \
    -B build \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

생성된 compile_commands.json에는 각 번역 단위의 실제 컴파일 명령이 기록된다. clangd, clang-tidy와 여러 편집기가 이 정보를 사용한다.

코드 포맷터

자동 포맷터는 들여쓰기, 줄바꿈과 공백 규칙을 일관되게 적용한다.

대표적인 도구는 다음과 같다.

  • clang-format
  • Artistic Style
  • uncrustify
  • IDE 내장 포맷터

clang-format 설정은 .clang-format 파일에 저장할 수 있다.

BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
BreakBeforeBraces: Attach
SortIncludes: true

소스 파일을 포맷할 수 있다.

clang-format \
    -i \
    src/main.cpp

전체 저장소를 무조건 다시 포맷하면 코드 리뷰에서 실제 변경과 서식 변경이 섞일 수 있다. 프로젝트는 도입 시점과 적용 범위를 별도로 정할 수 있다.

정적 분석

정적 분석은 프로그램을 실행하지 않고 소스 코드와 중간 표현을 검사해 잠재적인 오류를 찾는다.

C++ 정적 분석 도구
  • 컴파일러 기반
    • Clang Static Analyzer
    • clang-tidy
    • GCC -fanalyzer
    • MSVC Code Analysis
  • 독립 도구
    • PVS-Studio
    • Coverity
    • CodeQL
    • SonarQube
    • cppcheck
    • 상용 안전성 분석기

clang-tidy

clang-tidy는 Clang 기반의 C++ 린터와 정적 분석 도구다. 버그 가능성, 현대 C++ 전환, 성능, 가독성, C++ Core Guidelines와 여러 프로젝트 규칙을 검사한다.

clang-tidy \
    src/main.cpp \
    -p build

검사 항목을 지정할 수 있다.

clang-tidy \
    src/main.cpp \
    -p build \
    -checks='bugprone-*,performance-*,modernize-*'

프로젝트 설정은 .clang-tidy 파일에 저장할 수 있다.

Checks: >
  bugprone-*,
  performance-*,
  modernize-*,
  readability-*
WarningsAsErrors: >
  bugprone-use-after-move
HeaderFilterRegex: >
  ^.*/include/project/.*

자동 수정이 가능한 검사도 있지만 의미가 변하지 않는지 코드 리뷰와 테스트로 확인해야 한다.

컴파일러 경고

컴파일러 경고는 가장 기본적인 정적 검사 수단이다.

clang++ \
    -std=c++23 \
    -Wall \
    -Wextra \
    -Wpedantic \
    -Wconversion \
    -Wsign-conversion \
    -Wshadow \
    -Wnon-virtual-dtor \
    main.cpp

모든 경고를 무조건 활성화하면 시스템 헤더와 외부 라이브러리에서 지나치게 많은 진단이 발생할 수 있다. 프로젝트 소스와 외부 의존성의 경고 수준을 분리할 수 있다.

sanitizer

sanitizer는 컴파일 시 검사 코드를 삽입하고 실행 중 오류를 탐지한다. Clang과 GCC 계열 도구 체계에서 널리 사용된다.

대표적인 sanitizer
  • AddressSanitizer
    • 힙 범위 초과
    • 스택 범위 초과
    • use-after-free
    • 일부 수명 오류
  • UndefinedBehaviorSanitizer
    • 잘못된 시프트
    • 부호 있는 정수 오버플로
    • 정렬되지 않은 접근
    • 널 포인터 역참조
    • 일부 범위 오류
  • ThreadSanitizer
    • 데이터 경쟁
    • 일부 동기화 오류
  • MemorySanitizer
    • 초기화되지 않은 메모리 읽기
  • LeakSanitizer
    • 메모리 누수

AddressSanitizer는 컴파일러 계측과 런타임 라이브러리를 이용해 힙·스택·전역 객체의 범위 초과와 해제 후 사용 등을 탐지한다.[148]

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=address \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

UndefinedBehaviorSanitizer는 정렬 오류, 범위를 벗어난 시프트, 일부 정수 오버플로와 널 포인터 접근 등 여러 미정의 동작을 검사한다.[149]

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=undefined \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

두 검사를 함께 사용할 수 있다.

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=address,undefined \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

ThreadSanitizer는 데이터 경쟁을 탐지하는 실행 도구다.[150]

clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=thread \
    -fno-omit-frame-pointer \
    main.cpp \
    -o application

AddressSanitizer와 ThreadSanitizer는 일반적으로 같은 실행 파일에 동시에 적용하지 않는다. 별도의 CI 구성으로 실행할 수 있다.

디버거

디버거는 실행 중인 프로그램을 중단하고 스택, 변수, 메모리와 레지스터를 조사할 수 있게 한다.

대표적인 C++ 디버거는 다음과 같다.

  • GDB
  • LLDB
  • Visual Studio Debugger
  • WinDbg
  • 플랫폼 SDK 디버거
  • 임베디드 하드웨어 디버거

디버그 정보를 포함해 프로그램을 빌드할 수 있다.

clang++ \
    -std=c++23 \
    -g \
    -O0 \
    main.cpp \
    -o application

GDB로 실행한다.

gdb ./application

LLDB로 실행할 수도 있다.

lldb ./application

일반적인 디버깅 과정은 다음과 같다.

일반적인 디버깅 과정
  1. 디버그 심볼을 포함해 빌드
  2. 중단점 설정
  3. 프로그램 실행
  4. 문제가 발생한 위치에서 중단
  5. 호출 스택과 지역 변수 조사
  6. 한 문장 또는 명령 단위로 진행
  7. 원인 가설 검증
  8. 코드 수정과 회귀 테스트

최적화 빌드에서는 변수가 제거되거나 문장 순서가 소스와 다르게 보일 수 있다. 실제 배포 구성에서만 발생하는 문제를 조사하려면 최적화와 디버그 정보를 함께 사용하는 구성을 만들 수 있다.

clang++ \
    -std=c++23 \
    -O2 \
    -g \
    main.cpp \
    -o application

코어 덤프와 사후 디버깅

프로그램이 충돌할 때 실행 상태를 코어 덤프나 덤프 파일로 저장할 수 있다.

사후 디버깅
  1. 배포 프로그램 충돌
  2. 코어 덤프 또는 크래시 덤프 생성
  3. 실행 파일과 정확한 디버그 심볼 확보
  4. 디버거에서 덤프 열기
  5. 충돌 스레드와 호출 스택 확인
  6. 소스 리비전과 빌드 설정 대조

배포 바이너리에서 심볼을 제거하더라도 별도의 심볼 서버나 아티팩트 저장소에 디버그 정보를 보관할 수 있다.

프로파일러

프로파일러는 프로그램의 실행 시간, 함수 호출, CPU 사용률, 메모리 할당과 하드웨어 카운터를 측정한다.

대표적인 도구는 다음과 같다.

도구주요 환경
Linux perfLinux CPU 성능 분석
Valgrind Callgrind명령 수준 실행 비용 분석
Intel VTune ProfilerIntel 플랫폼 성능 분석
AMD uProfAMD CPU 성능 분석
Visual Studio ProfilerWindows와 Visual Studio
InstrumentsmacOS와 Apple 플랫폼
Tracy실시간 프레임·작업 추적
Google Benchmark마이크로벤치마크
heaptrack힙 할당 분석
Valgrind Massif힙 사용량 변화 분석

프로파일링 과정은 측정 전 추측보다 실제 데이터에 기반해야 한다.

성능 최적화 과정
  1. 성능 목표와 측정 환경 정의
  2. 현실적인 입력으로 기준 성능 측정
  3. 프로파일러로 병목 구간 식별
  4. 원인 분석
  5. 제한된 코드 변경
  6. 동일한 환경에서 다시 측정
  7. 회귀 테스트와 정확성 확인

마이크로벤치마크는 작은 함수나 알고리즘을 반복 측정하지만 실제 애플리케이션의 캐시, I/O와 작업 스케줄링을 완전히 재현하지 못할 수 있다.

테스트 프레임워크

C++ 표준은 단위 테스트 프레임워크를 제공하지 않는다. 프로젝트는 외부 프레임워크나 자체 테스트 실행기를 사용한다.

대표적인 프레임워크는 다음과 같다.

  • GoogleTest
  • Catch2
  • Boost.Test
  • doctest
  • Qt Test
  • Microsoft C++ Unit Test Framework
  • CTest와 사용자 정의 테스트 실행 파일

GoogleTest

GoogleTest는 테스트 케이스, 픽스처, 단정, 매개변수화 테스트와 모킹 기능을 제공한다.

#include <gtest/gtest.h>

TEST(
    CalculatorTest,
    AddsTwoNumbers
)
{
    Calculator calculator;

    EXPECT_EQ(
        calculator.add(20, 22),
        42
    );
}

CMake에서 테스트 실행 파일을 만들 수 있다.

add_executable(
    calculator_tests
    tests/CalculatorTests.cpp
)

target_link_libraries(
    calculator_tests
    PRIVATE
        calculator
        GTest::gtest_main
)

include(GoogleTest)

gtest_discover_tests(
    calculator_tests
)

Catch2

Catch2는 C++ 중심의 단위 테스트 프레임워크이며 기본적인 마이크로벤치마크와 BDD 형태의 매크로도 제공한다.[151]

#include <catch2/catch_test_macros.hpp>

TEST_CASE(
    "두 정수를 더한다."
)
{
    Calculator calculator;

    REQUIRE(
        calculator.add(20, 22)
        == 42
    );
}

CMake에서 Catch2 테스트를 등록할 수 있다.

add_executable(
    calculator_tests
    tests/CalculatorTests.cpp
)

target_link_libraries(
    calculator_tests
    PRIVATE
        calculator
        Catch2::Catch2WithMain
)

include(Catch)

catch_discover_tests(
    calculator_tests
)

테스트 계층

테스트는 범위에 따라 나눌 수 있다.

C++ 프로젝트의 테스트 계층
  • 단위 테스트
    • 작은 함수와 클래스
    • 빠른 실행
    • 외부 의존성 최소화
  • 통합 테스트
    • 여러 모듈의 결합
    • 파일과 데이터베이스
    • 실제 라이브러리 연동
  • 시스템 테스트
    • 전체 실행 파일
    • 명령줄 인터페이스
    • 사용자 시나리오
  • 성능 테스트
    • 마이크로벤치마크
    • 처리량
    • 지연 시간
    • 메모리 사용량
  • 회귀 테스트
    • 과거에 수정한 오류의 재발 방지

테스트 코드도 실제 제품 코드와 동일한 컴파일러 경고, sanitizer와 여러 플랫폼에서 검사할 수 있다.

CTest

CTest는 CMake와 함께 제공되는 테스트 실행 도구다.

테스트를 등록한다.

enable_testing()

add_executable(
    parser_tests
    tests/ParserTests.cpp
)

add_test(
    NAME
        parser.unit
    COMMAND
        parser_tests
)

빌드 후 테스트를 실행한다.

ctest \
    --test-dir build \
    --output-on-failure

레이블을 사용해 테스트 그룹을 나눌 수 있다.

set_tests_properties(
    parser.unit
    PROPERTIES
        LABELS
            "unit;parser"
)
ctest \
    --test-dir build \
    -L unit

코드 커버리지

코드 커버리지는 테스트 실행 중 어떤 코드가 수행됐는지를 측정한다.

대표적인 지표는 다음과 같다.

  • 문장 커버리지
  • 함수 커버리지
  • 분기 커버리지
  • 조건 커버리지
  • 경로 커버리지

GCC 계열에서 커버리지 계측을 활성화할 수 있다.

g++ \
    -std=c++23 \
    -O0 \
    -g \
    --coverage \
    main.cpp \
    tests.cpp \
    -o tests

커버리지가 높다고 해서 테스트 품질이 자동으로 높아지는 것은 아니다. 실행된 코드의 결과를 제대로 검증하지 않거나 중요한 경계 조건이 빠질 수 있다.

퍼징

퍼징은 자동으로 생성하거나 변형한 입력을 반복적으로 프로그램에 전달해 충돌, 단정 실패와 sanitizer 오류를 찾는다.

LLVM의 libFuzzer와 결합할 수 있다.

#include <cstddef>
#include <cstdint>
#include <span>

extern "C"
int LLVMFuzzerTestOneInput(
    const std::uint8_t* data,
    std::size_t size
)
{
    Parser parser;

    parser.parse(
        std::span(
            data,
            size
        )
    );

    return 0;
}
clang++ \
    -std=c++23 \
    -g \
    -O1 \
    -fsanitize=fuzzer,address,undefined \
    FuzzParser.cpp \
    Parser.cpp \
    -o fuzz_parser

퍼징은 파서, 파일 형식, 네트워크 프로토콜과 역직렬화 코드처럼 입력 공간이 넓은 구성 요소에 유용하다.

문서 생성

C++ API 문서는 소스 주석과 별도 문서를 바탕으로 생성할 수 있다.

대표적인 도구는 다음과 같다.

  • Doxygen
  • Sphinx
  • Breathe
  • MkDocs
  • clang-doc
  • 문서 사이트 생성기

Doxygen 형식의 주석은 다음과 같다.

/// 두 정수를 더한다.
///
/// @param left 왼쪽 피연산자
/// @param right 오른쪽 피연산자
/// @return 두 값의 합
int add(
    int left,
    int right
);

Doxygen 설정을 생성할 수 있다.

doxygen \
    -g \
    Doxyfile

문서를 생성한다.

doxygen Doxyfile

API 주석은 함수 이름을 그대로 반복하기보다 전제 조건, 소유권, 오류, 스레드 안전성과 수명 관계처럼 타입만으로 드러나지 않는 계약을 설명해야 한다.

버전 관리

C++ 프로젝트는 일반적으로 Git과 같은 분산 버전 관리 시스템을 사용한다.

git init

git add .

git commit \
    -m "Initial C++ project"

대형 바이너리 자산이나 미리 빌드된 SDK는 일반 Git 저장소에 직접 넣기보다 다음 방법을 사용할 수 있다.

  • Git LFS
  • 패키지 저장소
  • 릴리스 아티팩트
  • 사내 오브젝트 저장소
  • 외부 SDK 설치 스크립트

빌드 결과물은 일반적으로 소스 저장소에 커밋하지 않는다.

build/
out/
.cache/
compile_commands.json
*.o
*.obj
*.a
*.lib
*.so
*.dll
*.dylib
*.exe

지속적 통합

지속적 통합은 소스 변경마다 자동으로 빌드와 테스트를 수행한다.

C++ 지속적 통합 과정
  1. 소스 변경 제출
  2. 지원 도구 체계별 환경 생성
  3. 의존성 설치 또는 캐시 복원
  4. 프로젝트 구성
  5. 빌드
  6. 단위·통합 테스트
  7. 정적 분석과 sanitizer
  8. 패키징 또는 아티팩트 저장
  9. 결과를 코드 리뷰에 보고

다중 플랫폼 프로젝트는 빌드 행렬을 구성할 수 있다.

C++ CI 빌드 행렬
  • Linux
    • GCC와 libstdc++
    • Clang과 libstdc++
    • Clang과 libc++
    • sanitizer 구성
  • Windows
    • MSVC
    • clang-cl
    • 정적 런타임
    • 동적 런타임
  • macOS
    • Apple Clang
    • libc++
  • 추가 구성
    • Debug
    • Release
    • C++20
    • C++23
    • 모듈 사용
    • 독립 실행 환경

GitHub Actions의 간단한 예는 다음과 같다.

name: C++ CI

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Configure
        run: >
          cmake
          -S .
          -B build
          -G Ninja
          -DCMAKE_BUILD_TYPE=Debug

      - name: Build
        run: >
          cmake
          --build build

      - name: Test
        run: >
          ctest
          --test-dir build
          --output-on-failure

CI에서 ubuntu-latest나 도구의 이동하는 최신 태그만 사용하면 시간이 지나면서 컴파일러와 패키지 버전이 바뀔 수 있다. 중요한 배포 빌드는 컨테이너 이미지, 도구 버전과 의존성 잠금을 함께 관리할 수 있다.

컨테이너와 개발 환경

Docker와 같은 컨테이너는 빌드에 필요한 컴파일러, 라이브러리와 도구 버전을 고정하는 데 사용할 수 있다.

FROM ubuntu:24.04

RUN apt-get update && \
    apt-get install -y \
        clang \
        cmake \
        ninja-build \
        git && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /workspace

컨테이너 안에서 빌드한다.

docker build \
    -t cpp-build .
docker run \
    --rm \
    -v "$PWD:/workspace" \
    cpp-build \
    cmake \
    -S . \
    -B build \
    -G Ninja

컨테이너는 Linux 사용자 공간을 고정하지만 호스트 커널, CPU 아키텍처와 일부 하드웨어 기능까지 완전히 가상화하는 것은 아니다.

프로젝트 템플릿

새 C++ 프로젝트는 반복되는 기본 구성을 템플릿으로 만들 수 있다.

C++ 프로젝트의 일반적인 구성
  • 프로젝트
    • 공개 인터페이스
      • include/
    • 구현
      • src/
    • 테스트
      • tests/
    • 개발 도구
      • tools/
    • 문서
      • docs/
    • 빌드 구성
      • CMakeLists.txt
      • cmake/
    • 품질 설정
      • .clang-format
      • .clang-tidy
      • CI 설정

범용 라이브러리 생태계

C++에는 표준 라이브러리를 보완하는 다양한 범용 라이브러리가 존재한다.

Boost

Boost는 표준화 이전의 기능 실험과 범용 라이브러리를 제공해 온 대규모 C++ 라이브러리 집합이다. 스마트 포인터, 파일 시스템, 정규 표현식과 비동기 I/O 등 여러 Boost 구성 요소의 설계가 이후 C++ 표준 라이브러리에 영향을 주었다.

현대 Boost에는 다음과 같은 영역이 포함된다.

  • Asio
  • Beast
  • Graph
  • Geometry
  • Multiprecision
  • Process
  • Serialization
  • Program Options
  • Spirit
  • Test
  • Interprocess

Boost 전체를 하나의 단일 라이브러리처럼 사용하기보다 필요한 구성 요소를 선택할 수 있다.

Qt

Qt는 다중 플랫폼 GUI, 네트워크, 스레드, 데이터베이스, 멀티미디어와 애플리케이션 프레임워크를 제공한다.

Qt 생태계의 주요 영역
  • Qt
    • 사용자 인터페이스
      • Qt Widgets
      • Qt Quick·QML
    • 핵심 기능
      • 객체 모델
      • 이벤트 루프
      • 파일과 문자열
      • 스레드
    • 통신
      • 네트워크
      • WebSocket
      • IPC
    • 개발 도구
      • Qt Creator
      • Designer
      • Linguist
      • 배포 도구

Qt는 자체 객체 모델과 메타 객체 컴파일러를 사용하므로 순수한 ISO C++ 빌드 외에 추가 생성 단계가 포함될 수 있다.

그래픽스와 게임

대표적인 라이브러리와 프레임워크는 다음과 같다.

  • SDL
  • SFML
  • GLFW
  • Vulkan-Hpp
  • DirectXMath
  • Dear ImGui
  • bgfx
  • OGRE
  • OpenSceneGraph
  • 엔진별 SDK

C++는 Unreal Engine, Godot의 네이티브 계층, 여러 자체 엔진과 콘솔 SDK에서 사용된다.

네트워크와 서버

대표적인 라이브러리는 다음과 같다.

  • Boost.Asio
  • Boost.Beast
  • gRPC
  • libcurl
  • cpp-httplib
  • ZeroMQ
  • Protobuf
  • FlatBuffers
  • Cap’n Proto

네트워크 라이브러리는 운영체제 이벤트 시스템, 스레드 모델, TLS 라이브러리와 직렬화 형식에 따라 선택할 수 있다.

수치 계산과 과학 기술

대표적인 라이브러리는 다음과 같다.

  • Eigen
  • BLAS와 LAPACK 구현체
  • Intel oneMKL
  • OpenBLAS
  • FFTW
  • xtensor
  • Armadillo
  • CGAL
  • OpenCV
  • CUDA와 cuBLAS
  • SYCL 구현체

고성능 수치 라이브러리는 CPU 명령 집합, GPU, 메모리 배치와 병렬 실행 환경에 따라 서로 다른 백엔드를 사용할 수 있다.

데이터와 직렬화

대표적인 라이브러리는 다음과 같다.

  • nlohmann/json
  • RapidJSON
  • yaml-cpp
  • Protobuf
  • FlatBuffers
  • Boost.Serialization
  • cereal
  • SQLite C API 래퍼

파일과 네트워크 형식에서는 C++ 객체 메모리 표현을 그대로 저장하기보다 명시적인 스키마와 버전 규칙을 사용하는 것이 일반적이다.

헤더 전용 라이브러리

템플릿 중심 라이브러리는 별도 바이너리 없이 헤더만으로 배포될 수 있다.

#include <library/Algorithm.hpp>

auto result =
    library::transform(
        input
    );

헤더 전용 배포의 장점은 다음과 같다.

  • 별도 라이브러리 바이너리가 필요하지 않음
  • 템플릿 인스턴스화가 사용자 타입에 맞게 수행됨
  • 패키징이 단순할 수 있음
  • 컴파일러가 구현 전체를 최적화할 수 있음

한계는 다음과 같다.

  • 포함하는 번역 단위마다 구문 분석
  • 컴파일 시간 증가
  • 목적 파일 크기 증가 가능성
  • 구현 변경 시 광범위한 재컴파일
  • 공개 인터페이스와 구현 분리의 어려움

C++ 모듈은 반복적인 헤더 분석 문제를 줄일 가능성이 있지만 컴파일러와 빌드 시스템 지원 상태를 함께 고려해야 한다.

생태계의 분산성

C++에는 언어 전체를 대표하는 단일 공식 도구 체계가 없다.

C++ 생태계가 분산된 이유
  • 플랫폼 다양성
    • Windows
    • Linux
    • macOS
    • 임베디드
    • 게임 콘솔
    • GPU
  • 도구 체계 다양성
    • GCC
    • Clang
    • MSVC
    • 전용 컴파일러
  • ABI 다양성
    • Itanium 계열 ABI
    • Microsoft ABI
    • 플랫폼 전용 ABI
  • 배포 방식 다양성
    • 시스템 패키지
    • 소스 배포
    • 정적 라이브러리
    • 공유 라이브러리
    • 사내 SDK

이러한 분산성 때문에 프로젝트마다 도구 선택이 크게 다를 수 있다. Linux 시스템 라이브러리는 배포판 패키지와 Make를 사용할 수 있고, Windows 상용 애플리케이션은 Visual Studio와 MSBuild를 사용할 수 있으며, 다중 플랫폼 라이브러리는 CMake와 Conan 또는 vcpkg를 조합할 수 있다.

도구 선택 기준

도구는 인기도만으로 선택하기보다 프로젝트 요구 사항에 따라 평가할 수 있다.

영역확인할 조건
컴파일러표준 기능, 대상 아키텍처, 코드 생성 품질, 라이선스
표준 라이브러리기능 완성도, ABI, 운영체제 지원
빌드 시스템다중 플랫폼, 모듈, 증분 빌드, IDE 연동
패키지 관리자버전 해결, 바이너리 캐시, 사내 저장소, 교차 컴파일
IDE코드 분석, 디버거, 빌드 시스템과 원격 개발
테스트프레임워크 통합, 병렬 실행, 보고서 형식
분석 도구지원 컴파일러, 오탐률, CI 연동
프로파일러대상 플랫폼, 하드웨어 카운터, 추적 기능
CI플랫폼 행렬, 캐시, 비밀 관리, 아티팩트 보관
프로젝트 도구 선정 과정
  1. 지원 플랫폼과 배포 형식 결정
  2. 필요한 C++ 표준과 컴파일러 결정
  3. 빌드 시스템 선택
  4. 외부 의존성 관리 방식 선택
  5. IDE와 언어 분석 도구 연결
  6. 테스트·정적 분석·sanitizer 구성
  7. CI 빌드 행렬 작성
  8. 배포와 장기 유지 정책 검증

권장 개발 흐름

일반적인 C++ 프로젝트의 개발 흐름은 다음과 같이 구성할 수 있다.

C++ 개발 흐름
  1. 소스와 빌드 설정 작성
  2. 자동 포맷과 기본 경고 검사
  3. 증분 빌드
  4. 단위 테스트 실행
  5. 디버거로 오류 조사
  6. 정적 분석
  7. sanitizer 구성 실행
  8. 코드 리뷰
  9. 여러 플랫폼 CI
  10. 성능 프로파일링
  11. 릴리스 패키징

개발자 로컬 환경에서는 빠른 증분 빌드와 단위 테스트를 우선하고, CI에서는 여러 컴파일러와 강한 검사 구성을 추가할 수 있다.

cmake \
    -S . \
    -B build \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Debug

cmake \
    --build build

ctest \
    --test-dir build \
    --output-on-failure

sanitizer 빌드는 별도 디렉터리에 구성할 수 있다.

cmake \
    -S . \
    -B build/asan \
    -G Ninja \
    -DCMAKE_BUILD_TYPE=Debug \
    -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
cmake \
    --build build/asan

ctest \
    --test-dir build/asan \
    --output-on-failure

종합 프로젝트 구성

다음은 CMake, vcpkg, Catch2, clang-format, clang-tidy와 CI를 사용하는 프로젝트 예시다.

종합 C++ 프로젝트 구성
  • example/
    • 공개 헤더
      • include/example/Calculator.hpp
    • 구현
      • src/Calculator.cpp
    • 애플리케이션
      • app/main.cpp
    • 테스트
      • tests/CalculatorTests.cpp
    • 빌드 구성
      • CMakeLists.txt
      • cmake/
    • 의존성
      • vcpkg.json
    • 품질 설정
      • .clang-format
      • .clang-tidy
    • CI
      • .github/workflows/build.yml
    • 문서
      • README.md

공개 인터페이스는 다음과 같다.

#pragma once

namespace example {

class Calculator {
public:
    int add(
        int left,
        int right
    ) const noexcept;
};

}

구현은 별도 번역 단위에 둔다.

#include <example/Calculator.hpp>

int example::Calculator::add(
    int left,
    int right
) const noexcept
{
    return left + right;
}

테스트를 작성한다.

#include <example/Calculator.hpp>

#include <catch2/catch_test_macros.hpp>

TEST_CASE(
    "Calculator adds two integers"
)
{
    example::Calculator
        calculator;

    REQUIRE(
        calculator.add(20, 22)
        == 42
    );
}

CMake 프로젝트를 구성한다.

cmake_minimum_required(
    VERSION 3.25
)

project(
    Example
    VERSION 1.0.0
    LANGUAGES CXX
)

add_library(
    example
    src/Calculator.cpp
)

target_include_directories(
    example
    PUBLIC
        include
)

target_compile_features(
    example
    PUBLIC
        cxx_std_20
)

add_executable(
    application
    app/main.cpp
)

target_link_libraries(
    application
    PRIVATE
        example
)

enable_testing()

find_package(
    Catch2 3
    CONFIG
    REQUIRED
)

add_executable(
    example_tests
    tests/CalculatorTests.cpp
)

target_link_libraries(
    example_tests
    PRIVATE
        example
        Catch2::Catch2WithMain
)

include(Catch)

catch_discover_tests(
    example_tests
)

vcpkg 매니페스트는 테스트 프레임워크를 선언한다.

{
  "name": "example",
  "version-string": "1.0.0",
  "dependencies": [
    "catch2"
  ]
}

프로젝트 구성과 빌드는 다음과 같다.

cmake \
    -S . \
    -B build \
    -G Ninja \
    -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake \
    --build build

ctest \
    --test-dir build \
    --output-on-failure

정적 분석은 compilation database를 사용한다.

clang-tidy \
    src/Calculator.cpp \
    -p build

이 구성에서 각 도구는 다음 역할을 맡는다.

  • CMake가 실행 파일, 라이브러리와 테스트 대상의 관계를 정의한다.
  • Ninja가 생성된 빌드 그래프를 실행한다.
  • vcpkg가 Catch2 의존성을 설치하고 CMake에 제공한다.
  • Catch2가 단위 테스트를 등록하고 실행한다.
  • CTest가 프로젝트 전체 테스트를 통합 실행한다.
  • clang-format이 소스 형식을 통일한다.
  • clang-tidy가 잠재적인 오류와 코드 품질 문제를 검사한다.
  • compilation database가 IDE와 분석 도구에 실제 컴파일 옵션을 제공한다.
  • CI가 여러 운영체제와 컴파일러 조합을 반복 검증한다.

개발 도구와 생태계의 관계 정리

C++ 개발 환경은 언어 구현, 빌드, 의존성 관리와 품질 검증 도구가 결합된 형태로 구성된다. 컴파일러는 C++ 소스를 목적 코드로 변환하지만 대형 프로젝트를 관리하려면 빌드 시스템과 패키지 관리자가 필요하다. IDE와 언어 서버는 소스 탐색과 리팩터링을 지원하고, 디버거와 프로파일러는 실행 중의 상태와 성능을 분석한다.

CMake와 Meson은 여러 플랫폼의 빌드 구성을 관리하며, Ninja와 Make는 실제 빌드 명령을 실행한다. Conan과 vcpkg는 운영체제, 컴파일러, 아키텍처와 라이브러리 옵션이 결합된 C++ 의존성을 관리한다. 테스트 프레임워크, 정적 분석기와 sanitizer는 타입 검사만으로 발견하기 어려운 논리 오류, 메모리 오류와 데이터 경쟁을 검증한다.

C++ 생태계에는 하나의 공식 빌드 시스템이나 패키지 관리자만 존재하지 않는다. 프로젝트는 지원 플랫폼, ABI, 컴파일러, 외부 라이브러리와 배포 방식을 기준으로 도구를 선택해야 한다. 선택한 도구의 버전과 설정을 소스 저장소와 CI에 명시하면 개발자 환경과 배포 환경 사이의 차이를 줄일 수 있다.

활용 분야

C++는 운영체제와 컴파일러 같은 기반 소프트웨어부터 게임 엔진, 웹 브라우저, 임베디드 장치, 로봇, 과학 계산, 금융 거래 시스템과 인공지능 실행 환경에 이르기까지 넓은 분야에서 사용되는 범용 프로그래밍 언어다. 특히 높은 실행 성능, 예측 가능한 자원 사용, 네이티브 플랫폼 API 접근과 사용자 정의 추상화를 동시에 요구하는 소프트웨어에서 강점을 가진다.

C++가 모든 분야에서 가장 적합한 언어인 것은 아니다. 웹 서비스의 업무 로직, 간단한 자동화, 데이터 탐색과 빠른 애플리케이션 개발에서는 다른 언어가 더 높은 생산성을 제공할 수 있다. 실제 시스템에서는 성능과 자원 제어가 중요한 핵심 계층을 C++로 작성하고, 상위 제어·스크립트·사용자 인터페이스 계층을 Python, JavaScript, C#, Java나 다른 언어로 구성하기도 한다.

Standard C++ Foundation은 C++가 통신, 컴퓨터 그래픽스, 게임, 사용자 인터페이스, 임베디드 시스템, 금융 시스템, 의료 시스템, 항공 전자, 과학 계산 등 다양한 분야에 사용된다고 설명한다.[152]

C++의 주요 활용 분야
  • C++
    • 시스템 소프트웨어
      • 운영체제 구성 요소
      • 장치 드라이버 지원 계층
      • 런타임
      • 가상화
      • 시스템 유틸리티
    • 개발 도구
      • 컴파일러
      • 링커
      • 디버거
      • 정적 분석기
      • 프로파일러
    • 그래픽스와 게임
      • 게임 엔진
      • 렌더러
      • 물리 엔진
      • 에디터
      • 실시간 시뮬레이션
    • 애플리케이션
      • 데스크톱 소프트웨어
      • 웹 브라우저
      • 미디어 도구
      • CAD·DCC
      • 크로스 플랫폼 GUI
    • 임베디드와 로봇
      • 마이크로컨트롤러
      • 차량 제어
      • 산업 장비
      • 로봇 미들웨어
      • 엣지 컴퓨팅
    • 서버와 데이터 처리
      • 데이터베이스
      • 검색 엔진
      • 네트워크 서버
      • 저장소 엔진
      • 분산 시스템 구성 요소
    • 금융과 통신
      • 거래 시스템
      • 시세 처리
      • 위험 계산
      • 통신 장비
      • 저지연 메시징
    • 과학과 공학
      • 물리학
      • 수치 해석
      • 시뮬레이션
      • 영상 처리
      • 고성능 컴퓨팅
    • 인공지능
      • 텐서 연산 런타임
      • 모델 추론
      • GPU 커널 실행
      • 엣지 AI
      • 언어 바인딩의 네이티브 계층

활용 분야를 결정하는 특성

C++의 활용 범위는 단순히 “빠른 언어”라는 한 가지 특성만으로 설명되지 않는다. C++는 네이티브 실행, 정적 타입, 객체 수명 제어, 제네릭 프로그래밍, 운영체제 API 접근과 별도 컴파일을 함께 제공한다.

주요 선택 요인은 다음과 같다.

요구 사항C++가 제공하는 수단
높은 처리 성능네이티브 코드 생성, 인라인화, 벡터화와 최적화
예측 가능한 지연 시간명시적인 객체 수명, 사용자 정의 할당자와 메모리 배치
제한된 메모리값 타입, 스택 객체, 독립 실행 구현과 선택적 런타임
하드웨어 접근포인터, 비트 연산, 플랫폼 API와 컴파일러 내장 함수
대규모 추상화클래스, 템플릿, 개념과 표준 라이브러리
다중 플랫폼GCC, Clang, MSVC와 여러 플랫폼 전용 구현체
기존 네이티브 코드 통합C ABI, 정적·공유 라이브러리와 플랫폼 SDK
실시간 처리동적 할당과 잠금을 제한할 수 있는 설계
언어 간 연동C API, Python 바인딩, JNI와 각종 FFI
긴 제품 수명표준화된 언어와 광범위한 기존 코드베이스

C++는 추상화를 사용하면서도 그 비용을 분석하고 제어할 수 있도록 설계됐다. 표준 C++ 소개 자료도 C++를 타입이 풍부한 경량 추상화를 강조하며 자원 제약이 있는 소프트웨어 기반 환경에 적합한 범용 언어로 설명한다.[153]

C++가 선택되는 일반적인 조건
  1. 처리량·지연 시간·메모리 요구 분석
  2. 운영체제와 하드웨어 접근 필요성 확인
  3. 대상 플랫폼과 도구 체계 확인
  4. 필요한 추상화와 코드 규모 평가
  5. 개발 생산성과 유지보수 비용 비교
  6. 핵심 성능 계층에 C++ 적용 여부 결정

시스템 소프트웨어

C++는 운영체제의 모든 계층에서 동일하게 사용되는 것은 아니지만, 커널 주변의 시스템 서비스, 네트워크 데몬, 파일 처리 도구, 런타임, 가상화 도구와 플랫폼 프레임워크에 사용된다.

운영체제 커널 자체는 C나 Rust, 어셈블리 등 다른 언어를 중심으로 작성되는 경우가 많다. 커널 환경에서는 예외 처리, 동적 초기화, 완전한 표준 라이브러리와 일반적인 힙 사용이 제한될 수 있기 때문이다. 그러나 C++는 필요한 기능만 선택하고 독립 실행 환경에 맞춰 구성할 수 있으므로 일부 커널, 드라이버, 펌웨어와 저수준 런타임에서도 사용된다.

#include <cstdint>

class Register32 {
    volatile std::uint32_t* address_;

public:
    explicit Register32(
        std::uintptr_t address
    ) noexcept
        : address_(
              reinterpret_cast<
                  volatile std::uint32_t*
              >(address)
          )
    {
    }

    void write(
        std::uint32_t value
    ) noexcept
    {
        *address_ = value;
    }

    std::uint32_t read() const noexcept
    {
        return *address_;
    }
};

이와 같은 타입은 하드웨어 레지스터 주소와 접근 연산을 하나의 추상화로 묶는다. 실제 하드웨어 접근에서는 장치 명세, 메모리 장벽, 정렬과 플랫폼 규칙을 추가로 고려해야 한다.

시스템 서비스

운영체제 사용자 공간의 시스템 서비스에는 다음과 같은 프로그램이 포함될 수 있다.

  • 장치 관리 서비스
  • 네트워크 데몬
  • 오디오와 그래픽스 서비스
  • 파일 인덱서
  • 업데이트 서비스
  • 가상화 관리 도구
  • 컨테이너 런타임 구성 요소
  • 보안 에이전트
  • 백업과 동기화 프로그램

ChromiumOS 공식 개발 문서는 Chrome 브라우저 자체와 새로운 기능을 제공하는 시스템 데몬을 포함해 사용자 공간 코드의 상당 부분이 C++로 작성된다고 설명한다.[154]

런타임과 기반 라이브러리

다른 언어로 작성된 애플리케이션도 내부적으로 C++ 런타임이나 네이티브 라이브러리에 의존할 수 있다.

예를 들면 다음과 같다.

  • 언어 가상 머신
  • 그래픽스 런타임
  • 오디오 처리 엔진
  • 압축과 암호 라이브러리
  • 데이터베이스 클라이언트
  • 브라우저 엔진
  • 머신러닝 추론 런타임
  • 운영체제별 플랫폼 추상화 계층

상위 언어에서는 간단한 인터페이스만 보이지만 성능과 플랫폼 의존성이 높은 내부 계층은 C++로 구현될 수 있다.

컴파일러와 개발 도구

C++는 컴파일러, 정적 분석기, 디버거와 프로파일러처럼 프로그램을 분석하거나 변환하는 개발 도구에 사용된다.

LLVM 프로젝트는 재사용 가능한 컴파일러와 도구 체계 기술의 집합이며, LLVM 핵심 라이브러리와 Clang을 비롯한 주요 구성 요소가 C++로 개발된다. LLVM은 중간 표현 처리, 최적화, 어셈블, 디스어셈블과 대상 코드 생성에 필요한 라이브러리를 제공한다.[155][156]

C++로 구현되는 개발 도구의 구성
  • 컴파일러
    • 전처리기
    • 파서
    • 의미 분석기
    • 중간 표현
    • 최적화기
    • 코드 생성기
  • 개발 지원 도구
    • 언어 서버
    • 코드 포맷터
    • 정적 분석기
    • 리팩터링 도구
    • 디버거
    • 프로파일러

컴파일러는 복잡한 그래프와 트리 구조, 대규모 메모리 관리, 여러 대상 아키텍처 지원과 높은 처리 성능이 필요하다. C++의 값 타입, 템플릿과 다형성은 이러한 도구 내부의 추상 구문 트리, 타입, 명령과 분석 패스를 표현하는 데 사용될 수 있다.

class Expression {
public:
    virtual Type type() const = 0;

    virtual Value evaluate(
        EvaluationContext& context
    ) const = 0;

    virtual ~Expression() = default;
};

class BinaryExpression final :
    public Expression {
    Operator operator_;
    std::unique_ptr<Expression> left_;
    std::unique_ptr<Expression> right_;

public:
    Value evaluate(
        EvaluationContext& context
    ) const override;
};

실제 컴파일러는 가상 함수 외에도 방문자, 태그된 합 타입, 영역 할당자와 데이터 지향 구조를 조합할 수 있다.

게임 개발

C++는 상용 게임 엔진과 자체 게임 엔진에서 널리 사용된다. 게임은 렌더링, 물리, 애니메이션, 오디오, 네트워크, 인공지능, 자산 처리와 에디터를 하나의 실시간 시스템 안에서 결합한다.

Unreal Engine은 C++ 프로그래머를 위한 공식 프레임워크와 API를 제공하며, 게임플레이 클래스, 엔진 시스템과 편집기 확장에 C++를 사용한다.[157] Epic Games의 현재 코딩 표준은 Unreal Engine이 기본적으로 C++20 언어 모드로 컴파일되고 빌드에 최소 C++20을 요구한다고 설명한다.[158]

게임 엔진의 주요 C++ 활용 영역은 다음과 같다.

게임 엔진에서의 C++ 활용
  • 런타임
    • 게임 루프
    • 객체와 월드 관리
    • 메모리 관리
    • 작업 스케줄러
  • 그래픽스
    • 렌더링 백엔드
    • 셰이더 관리
    • 자원 스트리밍
    • 프레임 그래프
  • 시뮬레이션
    • 물리
    • 충돌 감지
    • 애니메이션
    • 내비게이션
    • 게임 인공지능
  • 플랫폼
    • 창과 입력
    • 파일 시스템
    • 네트워크
    • 오디오
    • 콘솔 SDK
  • 개발 도구
    • 월드 에디터
    • 자산 임포터
    • 프로파일러
    • 빌드 도구
    • 패키징

게임 루프

void Game::run()
{
    using Clock =
        std::chrono::steady_clock;

    auto previous =
        Clock::now();

    while (!window_.should_close()) {
        auto current =
            Clock::now();

        double delta_time =
            std::chrono::duration<
                double
            >(
                current - previous
            ).count();

        previous = current;

        input_.poll();
        world_.update(delta_time);
        renderer_.render(world_);
        window_.present();
    }
}

실제 엔진은 고정 시간 간격 시뮬레이션, 렌더링 보간, 작업 병렬화, 프레임 지연과 플랫폼 이벤트를 추가로 처리한다.

스크립트 언어와의 결합

게임의 모든 로직을 C++로 작성할 필요는 없다. 엔진 핵심은 C++로 구현하고 게임플레이와 콘텐츠 로직에는 다음 수단을 사용할 수 있다.

  • Lua
  • C#
  • Python
  • 시각적 스크립팅
  • 자체 스크립트 언어
  • 데이터 기반 상태 기계
게임 엔진의 언어 계층
  1. 플랫폼과 렌더링 백엔드
  2. C++ 엔진 런타임
  3. 게임플레이 프레임워크
  4. 스크립트 또는 시각적 로직
  5. 게임 콘텐츠와 사용자 설정

C++는 성능이 중요한 공통 시스템에 사용하고, 빠른 수정이 필요한 콘텐츠 로직은 더 동적인 환경에 두는 방식이다.

컴퓨터 그래픽스

C++는 실시간 및 오프라인 컴퓨터 그래픽스 분야에 널리 사용된다.

대표적인 프로그램은 다음과 같다.

  • 실시간 렌더러
  • 경로 추적기
  • 게임 엔진
  • CAD
  • 3D 모델링 도구
  • 영상 합성 소프트웨어
  • 렌더 팜
  • 지리 정보 시각화
  • 의료 영상
  • 가상현실과 증강현실

C++는 Vulkan, Direct3D, Metal, OpenGL과 같은 네이티브 그래픽스 API를 직접 호출하고, GPU 자원과 명령 큐의 수명을 제어할 수 있다.

class Buffer {
    Device* device_;
    NativeBuffer handle_;

public:
    Buffer(
        Device& device,
        const BufferDescription&
            description
    );

    ~Buffer()
    {
        if (handle_) {
            device_->destroy_buffer(
                handle_
            );
        }
    }

    Buffer(const Buffer&) = delete;
    Buffer& operator=(
        const Buffer&
    ) = delete;
};

이 구조는 GPU 버퍼 수명을 객체 수명에 연결한다.

그래픽스 프로그램에서 C++가 제공하는 주요 이점은 다음과 같다.

  • GPU API와 직접 연결
  • 수학 타입과 연산자 정의
  • SIMD 최적화
  • 메모리 정렬과 배치 제어
  • 대규모 자원 관리
  • 멀티스레드 명령 생성
  • 플랫폼별 렌더링 백엔드 분리
  • 셰이더 컴파일 도구 통합

웹 브라우저

웹 브라우저는 HTML·CSS 파싱, JavaScript 실행, 그래픽스, 네트워크, 보안, 멀티프로세스 격리, 오디오와 비디오 처리를 결합하는 대규모 시스템 소프트웨어다.

Chromium은 더 안전하고 빠르며 안정적인 웹 경험을 목표로 하는 오픈 소스 브라우저 프로젝트로, 공식 소스 검색 페이지에서도 주 언어가 C++로 표시된다.[159] Chromium 개발자 문서는 브라우저 소스와 아키텍처, 테스트 및 C++ 개발 지침을 제공한다.[160]

브라우저에서 C++가 사용될 수 있는 영역은 다음과 같다.

  • 렌더링 엔진
  • DOM과 레이아웃
  • 네트워크 스택
  • 이미지 디코더
  • 오디오·비디오 처리
  • 샌드박스
  • GPU 프로세스
  • 프로세스 간 통신
  • 저장소
  • 플랫폼 UI
  • 개발자 도구의 네이티브 계층

브라우저는 신뢰할 수 없는 외부 데이터를 지속적으로 처리하므로 메모리 안전성이 특히 중요하다. 대형 C++ 브라우저 프로젝트는 스마트 포인터, 격리 프로세스, sandbox, fuzzing, sanitizer와 안전한 컨테이너 검사 등을 조합해 위험을 줄인다.

데스크톱 애플리케이션

C++는 Windows, Linux와 macOS에서 실행되는 네이티브 데스크톱 애플리케이션에 사용된다.

대표적인 분야는 다음과 같다.

  • 통합 개발 환경
  • 코드 편집기
  • CAD와 CAM
  • 3D 모델링
  • 영상·음향 편집
  • 과학 시각화
  • 데이터베이스 관리 도구
  • 파일 관리 도구
  • 기업용 클라이언트
  • 게임 런처
  • 장치 설정 프로그램

GUI는 운영체제 API를 직접 사용하거나 Qt, wxWidgets, JUCE와 같은 프레임워크를 통해 구성할 수 있다.

class SaveDocumentCommand final :
    public Command {
    Document& document_;
    FilePath path_;

public:
    void execute() override
    {
        Serializer serializer;

        serializer.write(
            document_,
            path_
        );
    }
};

대형 데스크톱 애플리케이션에서는 UI 이벤트 처리와 핵심 데이터 모델, 문서 형식, 렌더링과 플러그인 시스템을 분리한다.

C++는 다음 요구가 있는 데스크톱 프로그램에서 선택될 수 있다.

  • 대형 파일과 복잡한 데이터 처리
  • GPU 가속
  • 장시간 실행
  • 낮은 입력 지연
  • 운영체제와 장치 API 통합
  • 플러그인과 SDK 제공
  • 여러 플랫폼에서 동일한 핵심 코드 사용

모바일 애플리케이션의 네이티브 계층

모바일 애플리케이션 전체를 C++로 작성하는 경우는 데스크톱보다 적지만, Android NDK와 iOS의 Objective-C++·C++ 연동을 통해 성능이 중요한 공통 계층을 C++로 구현할 수 있다.

대표적인 영역은 다음과 같다.

  • 게임 엔진
  • 영상·음성 코덱
  • 카메라 처리
  • 암호화
  • 머신러닝 추론
  • 증강현실
  • 물리와 시뮬레이션
  • 여러 플랫폼에서 공유하는 비즈니스 핵심
  • 기존 C·C++ 라이브러리 통합
class ImageProcessor {
public:
    ProcessedImage process(
        std::span<
            const std::byte
        > encoded_data
    );
};

상위 UI는 Kotlin, Java, Swift나 Objective-C로 작성하고 C ABI나 플랫폼 바인딩을 통해 C++ 핵심 라이브러리를 호출할 수 있다.

모바일 환경에서는 다음을 추가로 고려해야 한다.

  • 배터리 사용량
  • 발열
  • 메모리 제한
  • 앱 수명 주기
  • ABI별 바이너리
  • 플랫폼별 패키징
  • 스토어 정책
  • 디버그 심볼과 충돌 분석
  • JNI 또는 Swift·Objective-C 브리지

임베디드 시스템

C++는 마이크로컨트롤러, 산업 장비, 소비자 전자제품과 IoT 장치에서 사용된다. 임베디드 환경은 메모리와 저장 공간이 제한되고, 특정 시간 안에 작업을 끝내야 하며, 하드웨어 레지스터와 인터럽트에 직접 접근해야 할 수 있다.

Arduino 공식 문서는 Arduino API가 C와 C++ 언어를 기반으로 한 함수, 변수와 구조로 구성된다고 설명한다.[161] Arduino Cloud도 장치 프로그래밍에 C++ 라이브러리를 제공한다.[162]

class Led {
    int pin_;

public:
    explicit Led(int pin)
        : pin_(pin)
    {
        pinMode(
            pin_,
            OUTPUT
        );
    }

    void set(bool enabled)
    {
        digitalWrite(
            pin_,
            enabled
                ? HIGH
                : LOW
        );
    }
};

C++는 장치 핀과 동작을 타입으로 묶을 수 있지만, 작은 마이크로컨트롤러에서는 예외, RTTI, 동적 할당과 대형 표준 라이브러리를 비활성화하거나 제한할 수 있다.

독립 실행 환경

extern "C"
void reset_handler()
{
    initialize_clock();
    initialize_memory();
    initialize_devices();

    Application application;

    while (true) {
        application.step();
    }
}

독립 실행 환경에서는 일반적인 main()이나 운영체제 서비스가 존재하지 않을 수 있다.

정적 자원 구성

template<
    class T,
    std::size_t Capacity
>
class StaticQueue {
    std::array<T, Capacity>
        storage_;

    std::size_t begin_ = 0;
    std::size_t size_ = 0;

public:
    bool push(const T& value);
    bool pop(T& value);
};

최대 크기가 정해진 컨테이너는 동적 할당 없이 예측 가능한 메모리 사용량을 제공할 수 있다.

실시간 시스템

실시간 시스템에서 중요한 것은 평균 실행 속도뿐 아니라 정해진 시간 안에 작업이 완료되는지다.

대표적인 분야는 다음과 같다.

  • 차량 제어
  • 산업 자동화
  • 로봇 제어
  • 오디오 처리
  • 항공 전자
  • 의료 장치
  • 통신 신호 처리
  • 게임 프레임 처리

C++는 동적 메모리 할당과 잠금 사용을 제한하고, 고정 크기 버퍼와 명시적인 작업 스케줄을 사용할 수 있다.

void AudioProcessor::process(
    std::span<const float> input,
    std::span<float> output
) noexcept
{
    for (std::size_t index = 0;
         index < output.size();
         ++index) {
        output[index] =
            filter_.process(
                input[index]
            );
    }
}

실시간 오디오 콜백 안에서는 다음 작업을 피할 수 있다.

  • 동적 메모리 할당
  • 파일 I/O
  • 장시간 뮤텍스 대기
  • 예측하기 어려운 시스템 호출
  • 무제한 반복
  • 지연될 수 있는 로그 출력
  • 예외 전파
실시간 C++ 설계의 주요 요소
  • 시간
    • 최악 실행 시간
    • 주기
    • 마감 시간
    • 지터
  • 메모리
    • 사전 할당
    • 고정 크기 버퍼
    • 메모리 풀
    • 페이지 부재 최소화
  • 동시성
    • 잠금 범위 제한
    • 단일 생산자·소비자 큐
    • 우선순위
    • 스레드 친화도
  • 안전성
    • 입력 검증
    • 오류 격리
    • 정적 분석
    • 코딩 표준

C++ 자체가 실시간성을 자동으로 보장하지는 않는다. 운영체제 스케줄러, 하드웨어, 메모리 계층과 전체 소프트웨어 설계가 함께 실시간 요구를 만족해야 한다.

로봇과 자율 시스템

C++는 로봇 제어, 센서 처리, 위치 추정, 경로 계획과 실시간 통신에 사용된다.

ROS 2의 rclcpp는 ROS 2와 상호 작용하기 위한 표준 C++ API를 제공한다.[163] rclcpp::Node는 게시자, 구독자, 서비스와 기타 ROS 엔터티를 만드는 주요 진입점으로 사용된다.[164]

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>

class StatusPublisher :
    public rclcpp::Node {
    rclcpp::Publisher<
        std_msgs::msg::String
    >::SharedPtr publisher_;

    rclcpp::TimerBase::SharedPtr
        timer_;

public:
    StatusPublisher()
        : Node("status_publisher")
    {
        publisher_ =
            create_publisher<
                std_msgs::msg::String
            >(
                "status",
                10
            );

        timer_ =
            create_wall_timer(
                std::chrono::seconds(1),
                [this] {
                    publish_status();
                }
            );
    }

private:
    void publish_status()
    {
        std_msgs::msg::String message;

        message.data =
            "running";

        publisher_->publish(message);
    }
};

로봇 소프트웨어에서 C++가 사용되는 주요 영역은 다음과 같다.

  • 센서 드라이버
  • 카메라와 LiDAR 처리
  • SLAM
  • 좌표 변환
  • 경로 계획
  • 제어기
  • 시뮬레이터
  • 실시간 통신
  • 차량 인터페이스
  • 하드웨어 추상화

로봇 시스템은 C++와 Python을 함께 사용하는 경우가 많다. C++는 처리량과 지연 시간이 중요한 노드에, Python은 실험·조율·학습·도구 작성에 사용될 수 있다.

자동차와 운송

자동차 소프트웨어에는 마이크로컨트롤러 펌웨어부터 인포테인먼트, 운전자 보조, 센서 융합과 시뮬레이션까지 서로 다른 계층이 존재한다.

C++가 사용될 수 있는 영역은 다음과 같다.

  • 전자 제어 장치
  • 차량 네트워크
  • 센서 융합
  • 카메라와 레이더 처리
  • 경로 계획
  • 인포테인먼트
  • 디지털 계기판
  • 진단 도구
  • 차량 시뮬레이터
  • 생산·검사 장비

안전 관련 기능에서는 일반 애플리케이션보다 엄격한 개발 절차가 요구된다.

  • 요구 사항 추적
  • 제한된 언어 부분집합
  • 정적 분석
  • 코드 리뷰
  • 단위·통합·하드웨어 테스트
  • 커버리지 분석
  • 결함 관리
  • 기능 안전 표준 준수
  • 컴파일러와 도구 검증

C++의 모든 기능을 자유롭게 사용하기보다 프로젝트가 허용하는 언어 부분집합과 코딩 규칙을 정의할 수 있다.

항공우주와 국방

항공 전자, 위성, 시뮬레이터와 지상 제어 시스템은 제한된 자원, 긴 제품 수명, 높은 신뢰성과 실시간 처리를 요구할 수 있다.

C++가 사용될 수 있는 영역은 다음과 같다.

  • 비행 시뮬레이터
  • 임무 계획
  • 센서 처리
  • 지상 관제
  • 영상 처리
  • 통신 시스템
  • 항법
  • 훈련 시스템
  • 시험·검증 도구

안전 등급이 높은 실행 소프트웨어에서는 동적 할당, 예외, RTTI, 재귀와 복잡한 템플릿 사용이 제한될 수 있다. 반면 지상 도구와 시뮬레이션에서는 더 넓은 C++ 기능과 표준 라이브러리를 사용할 수 있다.

같은 산업 안에서도 실행 환경에 따라 사용하는 C++의 범위가 다르다.

항공우주 시스템의 C++ 사용 범위
  • 비행 제어 계층
    • 제한된 런타임
    • 결정적 실행
    • 엄격한 코딩 규칙
    • 정적 검증
  • 임무·센서 계층
    • 수치 계산
    • 신호 처리
    • 실시간 통신
    • 하드웨어 인터페이스
  • 지상 도구
    • 시뮬레이션
    • 시각화
    • 데이터 분석
    • 계획 소프트웨어

서버와 네트워크 소프트웨어

C++는 낮은 지연 시간, 높은 처리량, 네트워크와 저장소의 세밀한 제어가 필요한 서버 구성 요소에 사용된다.

대표적인 영역은 다음과 같다.

  • 웹 서버
  • RPC 프레임워크
  • 프록시
  • 로드 밸런서
  • 메시지 브로커
  • 캐시
  • 검색 엔진
  • 스토리지 서비스
  • 게임 서버
  • 스트리밍 서버
  • 통신 게이트웨이
Task<Response>
handle_request(
    Request request
)
{
    auto user =
        co_await user_store_.find(
            request.user_id
        );

    if (!user) {
        co_return Response::not_found();
    }

    auto result =
        service_.process(
            *user,
            request.payload
        );

    co_return Response::ok(
        std::move(result)
    );
}

서버 프로그램은 다음 요소를 조합할 수 있다.

  • 비동기 I/O
  • 코루틴
  • 스레드 풀
  • 작업 큐
  • 메모리 풀
  • zero-copy 버퍼
  • 프로토콜 파서
  • TLS 라이브러리
  • 분산 추적
  • 성능 프로파일링

C++는 전체 웹 애플리케이션의 업무 로직보다 네트워크 엔진, 저장소, 직렬화, 프록시와 고성능 서비스에 더 자주 선택될 수 있다.

데이터베이스와 저장소

데이터베이스 시스템은 파서, 질의 최적화, 인덱스, 버퍼 캐시, 동시성 제어, 로그와 디스크 I/O를 처리한다. 이러한 구성 요소는 메모리 배치와 실행 성능에 민감하므로 C++가 사용된다.

C++가 활용되는 데이터베이스 영역은 다음과 같다.

  • 데이터베이스 서버
  • 저장소 엔진
  • 검색 인덱스
  • 키·값 저장소
  • 시계열 데이터베이스
  • 벡터 데이터베이스
  • 분산 캐시
  • 클라이언트 드라이버
  • 확장 모듈
  • 데이터 변환 도구
struct PageHeader {
    PageId id;
    std::uint32_t checksum;
    std::uint16_t slot_count;
    std::uint16_t free_offset;
};

class BufferPool {
public:
    PageGuard acquire(PageId id);
    void flush(PageId id);
};

PostgreSQL은 핵심적으로 C로 개발되지만 공식 문서는 C와 호환되는 C++로 동적 확장 함수를 작성할 수 있다고 설명한다.[165] 이는 데이터베이스 생태계에서 C++가 서버 자체뿐 아니라 확장과 클라이언트 계층에도 사용될 수 있음을 보여준다.

데이터베이스 내부 C++ 설계에서는 다음이 중요하다.

  • 데이터 표현의 안정성
  • 디스크 형식 버전
  • 페이지 정렬
  • 바이트 순서
  • 충돌 복구
  • 트랜잭션
  • 잠금과 MVCC
  • 메모리 풀
  • 쿼리 실행 벡터화
  • 관측 가능성

금융 시스템

금융 분야에서 C++는 지연 시간, 처리량과 예측 가능한 실행이 중요한 시스템에 사용된다.

대표적인 영역은 다음과 같다.

  • 시장 데이터 수신
  • 주문 관리
  • 거래소 연결
  • 알고리즘 거래
  • 가격 계산
  • 위험 관리
  • 파생상품 평가
  • 포트폴리오 시뮬레이션
  • 백테스트
  • 금융 데이터베이스와 메시징

거래 경로의 코드에서는 평균 성능뿐 아니라 꼬리 지연 시간이 중요할 수 있다.

struct MarketUpdate {
    InstrumentId instrument;
    Price bid;
    Price ask;
    Quantity bid_size;
    Quantity ask_size;
    SequenceNumber sequence;
};

void Strategy::on_market_update(
    const MarketUpdate& update
) noexcept
{
    order_book_.apply(update);

    if (auto order =
            model_.evaluate(order_book_)) {
        gateway_.submit(*order);
    }
}

저지연 시스템에서는 다음 방법이 사용될 수 있다.

  • 사전 할당
  • 고정 크기 메시지
  • lock-free 또는 단일 소유 큐
  • CPU 친화도
  • NUMA 고려
  • 커널 우회 네트워크
  • 이진 프로토콜
  • 메모리 복사 최소화
  • 정밀한 시간 측정
  • 기록과 재생

금융 기관의 모든 소프트웨어가 C++로 작성되는 것은 아니다. 데이터 분석과 전략 연구에는 Python, 업무 시스템에는 Java·C#·웹 기술, 데이터 조회에는 SQL이 함께 사용될 수 있다.

금융 소프트웨어의 언어 계층 예시
  • 거래 핵심 경로
    • C++
    • 네이티브 네트워크
    • 저지연 메시징
  • 분석과 연구
    • Python
    • R
    • 노트북 환경
  • 업무 시스템
    • Java
    • C#
    • 웹 애플리케이션
    • 데이터베이스
  • 연결 계층
    • 메시지 큐
    • RPC
    • 파일
    • 공용 데이터 형식

통신

통신 장비와 네트워크 인프라는 높은 패킷 처리량, 실시간 신호 처리, 긴 가동 시간과 하드웨어 접근을 요구할 수 있다.

C++가 사용될 수 있는 영역은 다음과 같다.

  • 기지국 소프트웨어
  • 라우터와 스위치 관리
  • 패킷 처리
  • 음성·영상 통신
  • SIP와 미디어 서버
  • 네트워크 시뮬레이터
  • 신호 처리
  • 망 관리
  • 통신 프로토콜 구현
class PacketReader {
    std::span<const std::byte>
        data_;

    std::size_t offset_ = 0;

public:
    template<
        std::unsigned_integral T
    >
    std::optional<T> read_be();
};

네트워크 프로토콜 파서는 외부 입력을 처리하므로 길이 검증, 정수 오버플로, 바이트 순서와 수명 오류를 엄격하게 다뤄야 한다.

과학 계산

C++는 수치 계산, 시뮬레이션과 대규모 과학 데이터 분석에 사용된다. 성능이 중요한 계산 커널을 직접 작성하면서 템플릿과 사용자 정의 수치 타입으로 높은 수준의 인터페이스를 제공할 수 있기 때문이다.

CERN에서 개발된 ROOT는 고에너지 물리학을 위한 데이터 분석 및 입출력 프레임워크이며 C++ 인터프리터와 C++ 객체 지속성을 제공한다.[166] ROOT는 대규모 과학 데이터의 처리, 통계 분석, 시각화와 저장을 지원하며 매일 수천 명의 물리학자가 데이터 분석과 시뮬레이션에 사용한다.[167]

C++가 사용되는 과학 분야는 다음과 같다.

  • 입자 물리학
  • 천문학
  • 유체 역학
  • 구조 해석
  • 분자 동역학
  • 기후 모델링
  • 지구과학
  • 전산 화학
  • 생물정보학
  • 통계 시뮬레이션
class Grid3D {
    std::vector<double> values_;
    std::size_t width_;
    std::size_t height_;
    std::size_t depth_;

public:
    double& operator()(
        std::size_t x,
        std::size_t y,
        std::size_t z
    )
    {
        return values_[
            z * width_ * height_ +
            y * width_ +
            x
        ];
    }
};

수치 계산에서는 다음 기술과 결합된다.

  • OpenMP
  • MPI
  • CUDA
  • HIP
  • SYCL
  • BLAS
  • LAPACK
  • FFT 라이브러리
  • 벡터 명령
  • 분산 파일 시스템
  • 병렬 I/O

고성능 컴퓨팅

고성능 컴퓨팅은 여러 CPU 코어, 노드와 GPU를 결합해 대규모 계산을 수행한다.

C++가 사용되는 이유는 다음과 같다.

  • MPI와 네이티브 통신 라이브러리 연동
  • SIMD와 GPU 가속
  • 메모리 레이아웃 제어
  • 템플릿을 통한 수치 커널 생성
  • 컴파일 시간 특수화
  • 이기종 하드웨어 대응
  • 기존 Fortran·C 라이브러리 연결
void update_grid(
    std::span<const double> input,
    std::span<double> output,
    std::size_t width,
    std::size_t height
)
{
    for (std::size_t y = 1;
         y + 1 < height;
         ++y) {
        for (std::size_t x = 1;
             x + 1 < width;
             ++x) {
            std::size_t index =
                y * width + x;

            output[index] =
                (
                    input[index - 1] +
                    input[index + 1] +
                    input[index - width] +
                    input[index + width]
                ) * 0.25;
        }
    }
}

실제 HPC 코드는 도메인 분할, 경계 통신, NUMA, 벡터화와 가속기 커널을 추가로 처리한다.

의료와 영상 처리

C++는 의료 영상, 장치 제어, 신호 분석과 진단 소프트웨어에 사용될 수 있다.

대표적인 영역은 다음과 같다.

  • CT와 MRI 영상 처리
  • 초음파
  • 내시경 영상
  • 환자 모니터링
  • 의료 장치 펌웨어
  • 수술 로봇
  • 3D 재구성
  • 영상 보관과 전송
  • 생체 신호 분석
  • 시뮬레이션

의료 분야에서는 성능뿐 아니라 안전성, 개인정보 보호, 규제 문서화와 검증 절차가 중요하다.

Image
normalize_scan(
    const Image& source,
    Intensity minimum,
    Intensity maximum
)
{
    Image result(
        source.width(),
        source.height()
    );

    for (PixelIndex index :
         source.indices()) {
        result[index] =
            normalize(
                source[index],
                minimum,
                maximum
            );
    }

    return result;
}

실제 의료 소프트웨어에서는 입력 데이터의 단위와 범위, 장치 오류, 추적 가능성과 감사 기록을 명확히 관리해야 한다.

오디오와 미디어

오디오·영상 소프트웨어는 많은 데이터를 일정한 시간 안에 처리해야 한다. C++는 코덱, 실시간 오디오, 영상 편집, 스트리밍과 미디어 파이프라인에서 사용된다.

대표적인 영역은 다음과 같다.

  • 오디오 플러그인
  • 디지털 오디오 워크스테이션
  • 실시간 믹서
  • 음성 통신
  • 영상 코덱
  • 미디어 플레이어
  • 방송 소프트웨어
  • 비디오 편집
  • 카메라 파이프라인
class GainProcessor {
    std::atomic<float> gain_ =
        1.0F;

public:
    void set_gain(float gain) noexcept
    {
        gain_.store(
            gain,
            std::memory_order_relaxed
        );
    }

    void process(
        std::span<float> samples
    ) noexcept
    {
        float gain =
            gain_.load(
                std::memory_order_relaxed
            );

        for (float& sample : samples) {
            sample *= gain;
        }
    }
};

실시간 오디오 스레드는 끊김을 방지하기 위해 동적 할당, 파일 접근과 장시간 잠금을 피해야 한다.

인공지능과 머신러닝

머신러닝 모델의 연구와 학습 인터페이스는 Python이 널리 사용되지만, 내부 텐서 연산, 장치 런타임, 모델 서빙, 모바일·임베디드 추론과 사용자 정의 연산은 C++로 구현되는 경우가 많다.

TensorFlow는 그래프를 구성하고 실행하기 위한 C++ API를 제공한다.[168] TensorFlow 공식 API 문서는 Python API가 가장 완전하고 사용하기 쉽지만, C++를 포함한 다른 언어 API가 프로젝트 통합과 그래프 실행 성능 측면에서 유용할 수 있다고 설명한다.[169]

C++가 활용되는 인공지능 영역은 다음과 같다.

인공지능 시스템에서의 C++ 활용
  • 훈련 프레임워크 내부
    • 텐서 저장소
    • 연산자
    • 자동 미분 런타임
    • 분산 통신
    • GPU 실행
  • 모델 서빙
    • 요청 처리
    • 배치 구성
    • 모델 로딩
    • 하드웨어 가속
    • 지연 시간 관리
  • 엣지 추론
    • 모바일
    • 임베디드 Linux
    • 마이크로컨트롤러
    • 카메라
    • 차량·로봇
  • 언어 바인딩
    • Python 확장
    • C API
    • Java·Kotlin 연결
    • C#·Rust 연결

엣지 추론

LiteRT는 엣지 플랫폼에서 고성능 머신러닝과 생성형 AI 모델을 배포하기 위한 런타임이며, 데스크톱과 임베디드·IoT 환경에 C++ API를 제공한다.[170] ARM 기반 장치용 빌드도 C++ API를 지원한다.[171]

class Model {
public:
    static Model load(
        const std::filesystem::path&
            path
    );

    Tensor infer(
        const Tensor& input
    );
};

Model model =
    Model::load(
        "model.bin"
    );

Tensor output =
    model.infer(input);

실제 추론 런타임은 CPU, GPU, NPU와 DSP 같은 여러 장치를 지원하고, 텐서 메모리와 장치 동기화를 관리한다.

Python과의 결합

Tensor
multiply(
    const Tensor& left,
    const Tensor& right
)
{
    return backend_multiply(
        left,
        right
    );
}

이 C++ 함수를 Python 확장 모듈로 노출할 수 있다.

머신러닝 프레임워크의 언어 계층
  1. Python 모델·연구 API
  2. 바인딩 계층
  3. C++ 그래프와 텐서 런타임
  4. CPU·GPU·가속기 백엔드
  5. 하드웨어

Python은 사용자 생산성과 생태계를 제공하고 C++는 계산 핵심과 장치 통합을 담당하는 구조다.

시뮬레이션

C++는 물리, 교통, 제조, 로봇과 군사 훈련 등 다양한 시뮬레이션에 사용된다.

시뮬레이션은 다음 특성을 가질 수 있다.

  • 많은 개체
  • 반복되는 시간 단계
  • 복잡한 수치 계산
  • 실시간 시각화
  • 결정적 재현
  • 병렬 처리
  • 하드웨어 입력
  • 장기간 실행
void Simulation::step(
    Duration delta_time
)
{
    forces_.calculate(world_);
    integrator_.integrate(
        world_,
        delta_time
    );

    collision_.detect(world_);
    collision_.resolve(world_);

    events_.dispatch();
}

시뮬레이션은 게임 엔진과 비슷한 구조를 가질 수 있지만 정확도, 단위, 검증과 재현성이 더 중요한 경우가 많다.

블록체인과 암호 기술

일부 블록체인 노드, 암호화 라이브러리, 지갑의 네이티브 계층과 분산 합의 구현에도 C++가 사용된다.

주요 요구 사항은 다음과 같다.

  • 암호 연산
  • 네트워크 프로토콜
  • 디스크 저장소
  • 대량 트랜잭션 처리
  • 결정적인 데이터 형식
  • 플랫폼 이식성
  • 장기 실행
  • 공격적인 외부 입력 검증
Hash
calculate_block_hash(
    const BlockHeader& header
)
{
    ByteWriter writer;

    writer.write_u32_le(
        header.version
    );

    writer.write_bytes(
        header.previous_hash
    );

    writer.write_bytes(
        header.merkle_root
    );

    return sha256(
        writer.bytes()
    );
}

암호 구현에서는 자체 알고리즘을 임의로 작성하기보다 검증된 암호 라이브러리와 명시적인 프로토콜 규격을 사용하는 것이 중요하다.

플러그인과 SDK

C++ 프로그램은 외부 개발자가 기능을 추가할 수 있도록 플러그인 인터페이스와 SDK를 제공할 수 있다.

대표적인 분야는 다음과 같다.

  • 게임 엔진
  • CAD
  • 3D 제작 도구
  • 오디오 플러그인
  • 편집기
  • 데이터베이스
  • 웹 서버
  • 과학 분석 도구

C++ 클래스 인터페이스를 직접 공유하면 컴파일러와 표준 라이브러리 ABI에 의존할 수 있다.

extern "C" {

struct PluginHandle;

struct PluginApi {
    std::uint32_t version;

    void (*initialize)(
        PluginHandle*
    );

    void (*update)(
        PluginHandle*,
        double
    );

    void (*shutdown)(
        PluginHandle*
    );
};

const PluginApi*
plugin_get_api(
    std::uint32_t host_version
);

}

C ABI와 함수 테이블을 사용하면 외부 경계를 좁게 유지할 수 있다.

언어 바인딩과 네이티브 확장

C++는 다른 언어의 네이티브 확장을 작성하는 데 사용된다.

대표적인 연결 대상은 다음과 같다.

  • Python
  • Java
  • Kotlin
  • C#
  • JavaScript
  • Lua
  • Ruby
  • Rust
  • Swift

C++ 내부 코드를 C ABI로 노출하거나 각 언어의 전용 바인딩 도구를 사용할 수 있다.

extern "C"
int image_resize(
    const ImageView* input,
    ImageView* output
)
{
    if (input == nullptr ||
        output == nullptr) {
        return error_invalid_argument;
    }

    try {
        resize_image(
            *input,
            *output
        );

        return success;
    }
    catch (...) {
        return error_internal;
    }
}

다른 언어는 이 함수를 FFI로 호출할 수 있다.

바인딩 계층에서는 다음을 명확히 해야 한다.

  • 메모리 소유권
  • 문자열 인코딩
  • 예외와 오류 코드
  • 스레드 안전성
  • 콜백 수명
  • 객체 파괴 함수
  • ABI 버전
  • 호출 규약

C++가 단독으로 사용되지 않는 경우

현대 소프트웨어는 하나의 언어로만 구성되지 않는 경우가 많다. C++는 성능과 플랫폼 접근이 중요한 계층에 배치되고 다른 언어와 역할을 나눌 수 있다.

다중 언어 시스템의 예
  • 사용자 인터페이스
    • JavaScript
    • TypeScript
    • Swift
    • Kotlin
    • C#
  • 업무와 서비스 로직
    • Java
    • C#
    • Go
    • Python
    • Rust
  • 네이티브 핵심
    • C++
    • C
    • 플랫폼 SDK
  • 가속기
    • CUDA
    • HIP
    • SYCL
    • 셰이더 언어
  • 데이터와 자동화
    • Python
    • SQL
    • 구성 언어

예를 들어 게임 개발에서는 C++ 엔진, C#이나 Lua 게임 로직, Python 자산 도구, HLSL·GLSL 셰이더를 함께 사용할 수 있다.

C++가 적합한 경우

C++는 다음 조건이 여러 개 겹칠 때 강한 선택지가 될 수 있다.

  • CPU 성능과 메모리 효율이 중요하다.
  • 낮거나 예측 가능한 지연 시간이 필요하다.
  • 운영체제나 하드웨어 API를 직접 사용해야 한다.
  • 여러 플랫폼에 네이티브 바이너리를 제공해야 한다.
  • 복잡한 자원 수명을 자동으로 관리해야 한다.
  • 기존 C·C++ 코드와 긴밀히 통합해야 한다.
  • 대규모 코드베이스에 정적 타입과 추상화가 필요하다.
  • 사용자 정의 할당과 데이터 배치가 필요하다.
  • 다른 언어에 네이티브 라이브러리를 제공해야 한다.
  • 제품이 오랜 기간 유지된다.
class File {
    NativeFile handle_;

public:
    explicit File(
        const Path& path
    );

    ~File()
    {
        native_close(handle_);
    }

    File(const File&) = delete;
    File& operator=(const File&) = delete;
};

이처럼 플랫폼 자원을 타입 안전한 값으로 감쌀 수 있다.

C++가 불리할 수 있는 경우

C++는 다음 상황에서 다른 언어보다 비용이 클 수 있다.

  • 짧은 자동화 스크립트
  • 빠른 프로토타입
  • 단순한 CRUD 웹 서비스
  • 런타임 수정이 중요한 사용자 스크립트
  • 메모리 안전성이 최우선이지만 네이티브 성능 요구가 낮은 프로그램
  • 개발 인력이 C++의 수명·템플릿·빌드 모델에 익숙하지 않은 프로젝트
  • 작은 팀이 복잡한 크로스 플랫폼 빌드를 직접 관리해야 하는 경우
  • 가비지 컬렉션과 동적 반사가 핵심인 업무 시스템
  • 브라우저 기반 사용자 인터페이스가 중심인 프로그램

C++는 소스 코드뿐 아니라 빌드 시스템, ABI, 패키지, 컴파일 시간과 디버깅을 함께 관리해야 한다.

높은 제어 능력
높은 구현 자유도
복잡한 언어 규칙
긴 컴파일 시간 가능성
ABI와 도구 체계 관리
메모리 안전성 책임

작은 도구나 관리 프로그램까지 모두 C++로 통일하면 개발 비용이 증가할 수 있다.

분야별 사용 방식

분야C++가 맡는 대표 역할함께 사용되는 언어·기술
운영체제시스템 서비스, 런타임, 플랫폼 계층C, Rust, 어셈블리
컴파일러파서, 최적화, 코드 생성, 분석 도구TableGen, Python
게임엔진, 렌더링, 물리, 에디터Lua, C#, Python, 셰이더
브라우저렌더링, 네트워크, 미디어, 샌드박스JavaScript, Rust
데스크톱네이티브 UI, 문서 모델, 플러그인QML, C#, JavaScript
임베디드펌웨어, 드라이버, 제어기C, 어셈블리
로봇센서, SLAM, 경로 계획, 제어Python, CUDA
서버프록시, 저장소, 저지연 서비스Go, Java, Python
금융거래 경로, 가격 계산, 시세 처리Python, Java, SQL
과학수치 커널, 시뮬레이션, 분석Python, Fortran, CUDA
인공지능런타임, 텐서 연산, 추론Python, CUDA, SYCL
미디어코덱, 실시간 오디오, 영상 처리Python, 셰이더

활용 분야의 변화

C++의 활용 방식은 언어와 하드웨어, 소프트웨어 구조의 변화에 따라 달라져 왔다.

초기에는 운영체제 도구, 통신과 대형 응용 프로그램에서 클래스 기반 추상화를 제공하는 언어로 확산됐다. 이후 템플릿과 표준 라이브러리가 발전하면서 범용 네이티브 애플리케이션과 라이브러리 개발에 널리 사용됐다.

현대에는 다음과 같은 변화가 나타난다.

  • 단일 스레드 프로그램에서 작업 기반 병렬 시스템으로 이동
  • CPU 중심 코드에서 GPU와 이기종 컴퓨팅으로 확대
  • 클래스 계층 중심 설계에서 값 타입과 데이터 지향 설계 병행
  • 수동 자원 관리에서 RAII와 스마트 포인터 사용 확대
  • 단일 언어 애플리케이션에서 다중 언어 계층 구조로 이동
  • 전체 프로그램을 C++로 작성하기보다 성능 핵심 계층에 집중
  • 정적 분석, sanitizer와 fuzzing을 통한 안전성 강화
  • 임베디드 장치에서 엣지 AI 런타임까지 활용 확대
현대 C++ 활용 구조의 변화
  1. 네이티브 프로그램 전체를 C++로 작성
  2. 대형 프레임워크와 라이브러리 생태계 형성
  3. 스크립트 언어와 C++ 런타임 결합
  4. 멀티코어와 GPU 활용 확대
  5. 성능 핵심·플랫폼 계층에 C++ 집중
  6. 여러 언어가 역할을 나누는 시스템 구성

활용 분야의 정리

C++는 하나의 특정 산업에 한정되지 않는 범용 언어지만, 모든 분야에 동일한 이유로 사용되지는 않는다.

게임과 그래픽스에서는 프레임 시간, GPU 자원과 대형 엔진 구조를 관리하기 위해 사용된다. 임베디드와 로봇에서는 제한된 자원과 하드웨어 접근을 처리하고, 금융과 통신에서는 높은 처리량과 낮은 지연 시간을 목표로 한다. 과학 계산과 인공지능에서는 대규모 수치 연산과 가속기 런타임의 기반이 되며, 브라우저와 데스크톱 소프트웨어에서는 운영체제 통합과 복잡한 네이티브 기능을 제공한다.

C++의 가장 일반적인 역할은 하드웨어와 운영체제에 가까운 저수준 제어와 대규모 소프트웨어 추상화 사이를 연결하는 것이다. 개발자는 값 타입, RAII, 템플릿과 표준 라이브러리를 사용해 높은 수준의 구조를 만들면서도 메모리 배치, 스레드, 시스템 호출과 장치 API를 직접 제어할 수 있다.

이러한 특성 때문에 C++는 성능, 자원 사용, 플랫폼 접근과 긴 수명이 동시에 중요한 소프트웨어에서 계속 사용된다. 반면 빠른 개발과 단순한 배포가 더 중요한 부분에서는 다른 언어와 결합해 시스템의 핵심 실행 계층을 담당하는 경우가 많다.

다른 프로그래밍 언어와의 관계

C++는 C (프로그래밍 언어)에서 출발했지만, 이후 여러 프로그래밍 언어와 상호 영향을 주고받으며 독자적인 언어 계열을 형성했다. C의 실행 모델과 문법을 계승하는 한편 클래스, 함수 오버로딩, 템플릿, 예외 처리와 결정적 객체 수명을 결합했고, 이러한 구성은 Java와 C#을 비롯한 이후 언어의 문법과 타입 체계, 제네릭 프로그래밍과 라이브러리 설계에 영향을 주었다.

반대로 C++ 역시 다른 언어와 연구 분야의 개념을 받아들였다. 클래스와 객체 지향 프로그래밍은 Simula의 영향을 받았고, 제네릭 프로그래밍과 표준 템플릿 라이브러리는 수학적 추상화와 알고리즘 연구에서 발전했다. 함수형 프로그래밍에서 사용되던 람다, 고차 함수와 불변 값 중심의 설계도 현대 C++에 흡수되었다.

현대의 프로그래밍 언어 관계는 단순한 선후 관계로 설명하기 어렵다. C++, Objective-C, Java, C#, Rust와 Wave는 일부 문법과 시스템 프로그래밍 영역을 공유하지만 실행 환경, 메모리 안전성, 객체 모델과 추상화 방식에서 서로 다른 선택을 한다.

C++와 주요 프로그래밍 언어의 관계

C

C++는 C를 직접적인 기반으로 삼아 개발됐다. 초기 명칭인 C with Classes가 보여주듯이, 기존 C 프로그램과 운영체제 인터페이스를 활용하면서 클래스와 추상화 기능을 추가하는 것이 초기 목표였다.

C++가 C에서 계승한 주요 요소는 다음과 같다.

  • 중괄호 기반 블록 문법
  • 함수와 별도 컴파일
  • 포인터와 배열
  • 구조체와 공용체
  • 산술·논리·비트 연산자
  • 전처리기
  • 정적 저장 영역과 자동 저장 영역
  • 기계 주소와 메모리 표현에 대한 접근
  • C ABI 및 운영체제 API와의 연결
struct Position {
    float x;
    float y;
};

void move_position(
    struct Position* position,
    float x,
    float y
)
{
    position->x += x;
    position->y += y;
}

같은 개념을 C++에서는 멤버 함수와 생성자를 가진 타입으로 표현할 수 있다.

class Position {
    float x_ = 0.0F;
    float y_ = 0.0F;

public:
    Position(
        float x,
        float y
    ) noexcept
        : x_(x),
          y_(y)
    {
    }

    void move(
        float x,
        float y
    ) noexcept
    {
        x_ += x;
        y_ += y;
    }
};

C에서는 데이터와 함수를 별도로 구성하는 경우가 일반적이며, C++에서는 데이터와 연산을 하나의 사용자 정의 타입에 결합할 수 있다.

공통 기반과 독립적 발전

C와 C++는 많은 문법을 공유하지만 현대에는 서로 독립된 ISO 표준을 가진 별개의 언어다. C는 WG14가, C++는 WG21이 표준화를 담당한다.

C++ 표준은 C와의 호환성을 별도의 부속서에서 다루며, C 전체가 C++의 엄밀한 부분집합은 아니다.[172]

C에서는 허용되지만 C++에서는 명시적 변환이 필요한 코드가 존재한다.

/* C */

#include <stdlib.h>

int* values =
    malloc(
        16 * sizeof(int)
    );

C++에서는 void*가 다른 객체 포인터로 자동 변환되지 않는다.

#include <cstdlib>

int* values =
    static_cast<int*>(
        std::malloc(
            16 * sizeof(int)
        )
    );

일반적인 C++ 코드에서는 컨테이너를 사용할 수 있다.

#include <vector>

std::vector<int> values(16);

C++는 C의 저수준 기능을 유지하면서 다음 요소를 추가했다.

  • 참조
  • 클래스와 접근 제어
  • 생성자와 소멸자
  • 함수 및 연산자 오버로딩
  • 상속과 가상 함수
  • 템플릿
  • 예외 처리
  • 이름공간
  • 표준 컨테이너와 알고리즘
  • 이동 의미론
  • 람다
  • 개념
  • 모듈
  • 코루틴

C++에서 C 라이브러리 사용

C++는 C 라이브러리와 운영체제 API를 직접 호출할 수 있다.

extern "C" {

struct NativeHandle;

NativeHandle*
native_create();

void native_destroy(
    NativeHandle* handle
);

}

C 자원을 RAII 객체로 감쌀 수 있다.

class NativeObject {
    NativeHandle* handle_;

public:
    NativeObject()
        : handle_(
              native_create()
          )
    {
        if (handle_ == nullptr) {
            throw NativeError();
        }
    }

    ~NativeObject()
    {
        native_destroy(handle_);
    }

    NativeObject(
        const NativeObject&
    ) = delete;

    NativeObject& operator=(
        const NativeObject&
    ) = delete;
};

C와 C++가 함께 사용되는 프로젝트에서는 일반적으로 C ABI가 두 언어 사이의 경계가 된다.

C와 C++의 일반적인 상호 운용
  1. C 호환 헤더에 함수와 구조체 선언
  2. C++에서 extern "C" 연결 지정
  3. C++ 내부 객체와 자원 관리 사용
  4. 오류 코드와 불투명 포인터로 변환
  5. C 프로그램 또는 다른 언어에서 호출

C와 C++의 관계는 ## C와의 관계에서 더 자세히 다룬다.

Objective-C

Objective-C는 C에 객체 지향 메시지 전달 모델을 추가한 언어다. C++와 마찬가지로 C를 확장하면서 등장했지만 객체 지향 프로그래밍을 구현하는 방식은 크게 다르다.

C++가 정적으로 결정되는 멤버 함수 호출과 클래스 계층을 중심으로 발전한 반면, Objective-C는 Smalltalk에서 영향을 받은 동적 메시지 전송을 중심으로 한다.

[document save];

[window
    setTitle:@"Document"
];

Objective-C의 메시지 전송은 수신 객체와 selector를 런타임에 연결한다. Apple의 Objective-C 런타임은 클래스, 객체, selector와 메시지 전달을 지원하는 동적 런타임 기능을 제공한다.[173]

C++의 일반적인 멤버 함수 호출은 다음과 같다.

document.save();

window.set_title(
    "Document"
);

C++ 구현체도 가상 함수를 런타임에 디스패치할 수 있지만, Objective-C의 메시지 전달과 동일한 객체 모델을 사용하지는 않는다.

객체 모델

특성C++Objective-C
C와의 관계C에서 출발한 독립 언어C에 객체 모델을 추가한 확장
메서드 호출직접 호출 또는 가상 디스패치동적 메시지 전송
클래스 배치컴파일러와 ABI가 결정Objective-C 런타임이 관리
다중 상속클래스 다중 상속 지원클래스 다중 상속 미지원
제네릭템플릿경량 제네릭과 런타임 객체 모델
자원 관리RAII와 값 수명참조 계수와 객체 런타임
메타프로그래밍템플릿과 constexpr런타임 클래스·selector 조회

Objective-C는 객체를 일반적으로 포인터로 다룬다.

NSString* title =
    @"Document";

C++에서는 객체를 값, 참조 또는 포인터로 사용할 수 있다.

std::string title =
    "Document";

Document document;
Document& reference =
    document;

Document* pointer =
    &document;

Objective-C++

Objective-C++는 하나의 번역 단위 안에서 Objective-C와 C++ 문법을 함께 사용할 수 있게 하는 언어 모드다. 일반적으로 .mm 확장자를 사용한다.

```objective-c++ id="cpp-language-relation-objcpp" #import <Foundation/Foundation.h>

#include <memory> #include <string>

class Engine { public: void start(); };

@interface ApplicationBridge : NSObject { std::unique_ptr<Engine> engine_; }

  • (void)startEngine;

@end

@implementation ApplicationBridge

  • (instancetype)init

{ self = [super init];

if (self) { engine_ = std::make_unique<Engine>(); }

return self; }

  • (void)startEngine

{ engine_->start(); }

@end

Objective-C++는 두 객체 모델을 하나로 통합하지 않는다. Objective-C 객체가 C++ 클래스를 상속하거나 C++ 클래스가 Objective-C 클래스를 상속하는 방식으로 사용할 수 없으며, 일반적으로 포인터와 합성을 통해 연결한다.

<wiki-tree title="Objective-C++의 역할" state="expanded">
Apple 플랫폼 계층
├── Cocoa와 Objective-C API
├── Objective-C 객체
└── 런타임 메시지 전달

Objective-C++ 연결 계층
├── Objective-C 클래스에서 C++ 객체 소유
├── C++ 코드에서 Objective-C 객체 포인터 사용
└── 데이터와 오류 형식 변환

C++ 핵심 계층
├── 엔진
├── 알고리즘
├── 플랫폼 공통 코드
└── 외부 네이티브 라이브러리 </wiki-tree>

Objective-C++는 게임 엔진, 그래픽스 라이브러리와 다중 플랫폼 C++ 코드가 macOS나 iOS 프레임워크와 연결될 때 사용된다.

### Java와 C#

Java와 C#은 C++와 유사한 중괄호 문법과 클래스 기반 객체 지향 구조를 사용하지만, 일반적인 실행 모델은 C++와 다르다. 두 언어는 관리형 런타임과 자동 메모리 관리를 중심으로 설계됐으며, 포인터와 객체 배치를 직접 제어하는 기능을 제한한다.

Oracle의 Java Virtual Machine Specification은 Java의 문법이 C와 C++와 유사하지만, C와 C++를 복잡하거나 혼란스럽고 안전하지 않게 만드는 여러 기능을 제외했다고 설명한다.<wiki-footnote>[Oracle: Java Virtual Machine Specification, Introduction](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-1.html)</wiki-footnote>

Microsoft는 C#을 C++, Java와 JavaScript 경험자에게 익숙한 문법을 가진 .NET 언어로 소개한다.<wiki-footnote>[Microsoft .NET: C#](https://dotnet.microsoft.com/languages/csharp)</wiki-footnote>

#### 공통 문법

세 언어는 기본 제어 흐름에서 유사한 형태를 가진다.

```cpp id="cpp-language-relation-loop-cpp"
for (int index = 0;
     index < count;
     ++index) {
    process(index);
}
for (int index = 0;
     index < count;
     ++index) {
    process(index);
}
for (int index = 0;
     index < count;
     ++index) {
    Process(index);
}

클래스 선언과 접근 제어, 예외 처리, 인터페이스와 제네릭도 표면적으로 유사한 부분이 있다.

실행 환경

C++는 일반적으로 대상 플랫폼의 네이티브 기계어로 컴파일되며, 객체의 저장 위치와 수명을 프로그램이 직접 구성할 수 있다.

Java는 일반적으로 Java 바이트코드로 컴파일되어 JVM에서 실행된다.

C#은 일반적으로 공통 중간 언어로 컴파일되어 .NET 런타임에서 실행된다. .NET은 JIT 컴파일뿐 아니라 Ahead-of-Time 컴파일도 지원할 수 있으므로 관리형 언어가 항상 실행 시점에만 기계어로 번역되는 것은 아니다.

<wiki-tree title="일반적인 실행 모델 비교" state="expanded"> C++ ├── 네이티브 목적 코드 ├── 플랫폼 ABI ├── 값 타입과 직접 객체 배치 └── 결정적 소멸

Java ├── Java 바이트코드 ├── JVM ├── 가비지 컬렉션 └── 플랫폼 라이브러리

C# ├── 공통 중간 언어 ├── .NET 런타임 ├── 가비지 컬렉션 └── JIT 또는 AOT 실행 </wiki-tree>

객체와 값

C++ 클래스 객체는 스택, 정적 저장 영역, 다른 객체 내부와 동적 저장 영역에 직접 배치될 수 있다.

class Vector2 {
public:
    float x;
    float y;
};

Vector2 position {
    .x = 10.0F,
    .y = 20.0F
};

Java의 일반 클래스 인스턴스는 참조를 통해 사용된다.

final class Vector2 {
    float x;
    float y;
}

Vector2 position =
    new Vector2();

C#은 클래스와 구조체를 구분한다.

public struct Vector2
{
    public float X;
    public float Y;
}

Vector2 position =
    new Vector2
    {
        X = 10.0F,
        Y = 20.0F
    };

C#의 구조체는 값 타입이지만, C++의 객체 모델과 수명 규칙을 그대로 따르지는 않는다.

자원 관리

C++는 객체의 소멸 시점이 언어 규칙에 따라 결정되며 RAII를 통해 파일, 잠금과 메모리 같은 자원을 관리한다.

void process()
{
    std::ifstream file(
        "document.txt"
    );

    std::lock_guard lock(
        mutex
    );

    read_document(file);
}

Java는 try-with-resources를 제공한다.

try (
    InputStream input =
        new FileInputStream(
            "document.txt"
        )
) {
    readDocument(input);
}

C#은 using 문과 IDisposable을 사용한다.

using FileStream stream =
    File.OpenRead(
        "document.txt"
    );

ReadDocument(stream);

Java와 C#의 가비지 컬렉션은 메모리를 자동 회수하지만 파일, 잠금과 네이티브 핸들 같은 외부 자원의 즉시 반환을 자동으로 해결하지는 않는다. 이 때문에 두 언어도 별도의 결정적 정리 문법과 인터페이스를 제공한다.

상속과 인터페이스

C++는 여러 클래스로부터 구현을 상속할 수 있다.

class Drawable {
public:
    virtual void draw() = 0;
    virtual ~Drawable() = default;
};

class Serializable {
public:
    virtual void serialize(
        Writer& writer
    ) const = 0;

    virtual ~Serializable() = default;
};

class Document :
    public Drawable,
    public Serializable {
};

Java와 C#은 일반적으로 클래스 구현 상속을 하나로 제한하고 여러 인터페이스를 구현한다.

final class Document
    extends Resource
    implements Drawable,
               Serializable {
}
public sealed class Document :
    Resource,
    IDrawable,
    ISerializable
{
}

이러한 인터페이스 중심 구조는 C++의 추상 클래스와 유사한 역할을 한다. 비야네 스트롭스트룹은 COM과 CORBA의 인터페이스 모델이 C++ 추상 클래스에서 유래한 구성 요소 모델의 영향을 받았다고 설명했다.<wiki-footnote>Bjarne Stroustrup, Evolving a Language in and for the Real World: C++ 1991–2006</wiki-footnote>

템플릿과 제네릭

C++ 템플릿은 타입이나 값을 매개변수로 받아 번역 시점에 코드와 타입을 생성한다.

template<class T>
T maximum(
    T left,
    T right
)
{
    return left < right
        ? right
        : left;
}

Java와 C#은 제네릭을 제공한다.

static <T extends Comparable<T>>
T maximum(
    T left,
    T right
) {
    return left.compareTo(right) < 0
        ? right
        : left;
}
static T Maximum<T>(
    T left,
    T right
)
    where T : IComparable<T>
{
    return left.CompareTo(right) < 0
        ? right
        : left;
}

C++ 템플릿은 다음과 같은 기능을 제공한다.

  • 비타입 템플릿 매개변수
  • 부분 특수화
  • 명시적 특수화
  • 연산 기반 구조적 요구
  • 개념
  • 컴파일 시간 계산
  • 표현식과 타입 생성

Java와 C# 제네릭은 관리형 런타임의 타입 체계와 결합한다. Java는 일반적으로 타입 소거를 중심으로 제네릭을 구현하며, C# 제네릭은 CLR 타입 체계에 더 직접적으로 표현된다.

스트롭스트룹은 Java와 C#의 제네릭이 C++ 템플릿과 정적 타입 컨테이너의 영향을 받았다고 설명했다.<wiki-footnote>Bjarne Stroustrup, Evolving a Language in and for the Real World: C++ 1991–2006</wiki-footnote>

네이티브 코드와의 연결

Java는 JNI를 통해 C와 C++ 코드를 호출할 수 있다.

extern "C"
JNIEXPORT jint JNICALL
Java_Calculator_add(
    JNIEnv* environment,
    jobject object,
    jint left,
    jint right
)
{
    return left + right;
}

C#은 P/Invoke와 네이티브 상호 운용 기능을 사용할 수 있다.

[DllImport(
    "native",
    CallingConvention =
        CallingConvention.Cdecl
)]
private static extern int Add(
    int left,
    int right
);

C++ 라이브러리는 일반적으로 C ABI 래퍼를 통해 Java와 C#에 노출된다.

Rust

Rust는 C++와 비슷한 시스템 프로그래밍 영역을 대상으로 하는 정적 타입 언어다. 두 언어 모두 가비지 컬렉션을 필수로 하지 않으며, 네이티브 코드 생성, 값 타입, 제네릭 프로그래밍과 결정적 자원 반환을 제공한다.

가장 큰 차이는 메모리와 별칭 안전성을 검사하는 방식이다. C++는 포인터, 참조와 객체 수명에 대한 강력한 기능을 제공하지만, 많은 수명 규칙을 프로그래머의 책임으로 둔다. Rust는 소유권, 이동, 차용과 수명을 타입 검사에 통합한다.

소유권과 이동

C++에서는 이동 생성자와 이동 대입 연산자를 통해 자원 소유권을 이전할 수 있다.

std::unique_ptr<Resource>
    first =
        std::make_unique<
            Resource
        >();

std::unique_ptr<Resource>
    second =
        std::move(first);

이동 뒤의 first는 여전히 유효한 객체지만 내부 포인터는 비어 있다.

Rust에서는 일반적인 값 대입이 소유권 이동이 될 수 있다.

let first =
    Box::new(
        Resource::new()
    );

let second =
    first;

// first는 이후 사용할 수 없다.

Rust 컴파일러는 이동된 값을 다시 사용하는 코드를 거부한다.

RAII와 Drop

C++는 소멸자를 통해 자원을 정리한다.

class File {
    NativeFile handle_;

public:
    ~File()
    {
        native_close(handle_);
    }
};

Rust는 Drop trait을 제공한다.

struct File {
    handle: NativeFile,
}

impl Drop for File {
    fn drop(&mut self) {
        native_close(
            self.handle
        );
    }
}

두 언어 모두 객체의 수명이 끝날 때 정리 코드를 실행하는 결정적 자원 관리가 가능하다. Rust 공식 FFI 문서도 외부 라이브러리가 넘긴 자원을 Rust 소멸자로 관리해 안전하게 반환할 수 있다고 설명한다.<wiki-footnote>The Rustonomicon: FFI and Destructors</wiki-footnote>

참조와 차용

C++ 참조는 유효한 객체를 가리켜야 하지만, 일반적인 컴파일러는 모든 참조 수명을 정적으로 증명하지 않는다.

const std::string&
invalid_reference()
{
    std::string local =
        "text";

    return local;
}

이 함수는 지역 객체에 대한 유효하지 않은 참조를 반환한다.

Rust는 차용 검사기를 통해 같은 구조를 거부한다.

fn invalid_reference()
    -> &String
{
    let local =
        String::from("text");

    &local
}

Rust에서는 반환 참조가 가리키는 값이 충분히 오래 살아 있음을 증명해야 한다.

가변 별칭

Rust의 안전한 참조는 기본적으로 다음 규칙을 따른다.

  • 여러 개의 불변 참조
  • 하나의 배타적 가변 참조
fn increment(
    value: &mut i32
) {
    *value += 1;
}

C++에서는 여러 포인터와 참조가 같은 객체를 가리킬 수 있으며, 데이터 경쟁과 잘못된 무효화 여부를 프로그램이 관리한다.

void increment(int& value)
{
    ++value;
}

C++의 const는 수정 가능성을 제한하지만 Rust의 차용 규칙과 동일한 배타성 모델을 제공하지는 않는다.

unsafe

Rust는 모든 저수준 연산을 제거하지 않는다. 원시 포인터 역참조, 외부 함수 호출과 특정 전역 상태 접근은 unsafe 영역에서 수행한다.

unsafe fn read_register(
    address: usize
) -> u32 {
    let pointer =
        address as *const u32;

    pointer.read_volatile()
}

unsafe는 해당 블록 내부의 안전 조건을 프로그래머가 검증해야 한다는 경계를 표시한다. C++에서는 원시 포인터와 저수준 연산이 일반 문법에 포함되며 별도의 unsafe 키워드가 없다.

다형성과 제네릭

C++는 템플릿과 개념을 사용한다.

template<class T>
concept Drawable =
    requires(
        const T& value,
        Renderer& renderer
    ) {
        value.draw(renderer);
    };

template<Drawable T>
void render(
    const T& value,
    Renderer& renderer
)
{
    value.draw(renderer);
}

Rust는 trait과 제네릭을 사용한다.

trait Drawable {
    fn draw(
        &self,
        renderer: &mut Renderer
    );
}

fn render<T: Drawable>(
    value: &T,
    renderer: &mut Renderer
) {
    value.draw(renderer);
}

두 언어 모두 정적 디스패치와 동적 디스패치를 지원한다.

void render(
    const DrawableBase&
        value
);
fn render(
    value: &dyn Drawable
) {
}

오류 처리

C++는 예외, 오류 코드와 std::expected를 사용할 수 있다.

std::expected<
    Document,
    Error
>
load_document(
    const Path& path
);

Rust는 Result를 표준적인 오류 전달 타입으로 사용한다.

fn load_document(
    path: &Path
) -> Result<Document, Error> {
}

Rust의 ? 연산자는 오류를 호출자에게 전달한다.

let document =
    load_document(path)?;

C++23의 std::expected는 값 또는 오류를 표현하지만 Rust의 언어 연산자와 동일한 오류 전파 문법을 제공하지는 않는다.

상호 운용

Rust와 C++는 언어 고유 ABI를 직접 공유하기보다 C ABI를 통해 연결하는 경우가 많다.

Rust 함수를 C 호출 규약으로 내보낼 수 있다.

#[unsafe(no_mangle)]
pub extern "C"
fn calculate(
    left: i32,
    right: i32
) -> i32 {
    left + right
}

C++에서 호출한다.

extern "C"
int calculate(
    int left,
    int right
);

int result =
    calculate(20, 22);

Rust 공식 문서는 extern "C"와 심볼 이름 고정을 통해 Rust 코드를 C에서 호출할 수 있다고 설명한다.<wiki-footnote>The Rustonomicon: Calling Rust code from C</wiki-footnote>

복잡한 C++ 타입, 템플릿과 예외를 직접 연결하려면 별도의 브리지 도구나 수동 래퍼가 필요하다.

<wiki-tree title="C++와 Rust의 주요 차이" state="expanded"> 공통점 ├── 네이티브 코드 생성 ├── 가비지 컬렉션 비필수 ├── 값 타입 ├── 결정적 자원 반환 ├── 정적·동적 다형성 └── 시스템 API 접근

C++ ├── 기존 C·C++ 생태계와 ABI ├── 템플릿과 개념 ├── 클래스 상속 ├── 예외 처리 ├── 직접적인 포인터와 참조 └── 광범위한 미정의 동작 규칙

Rust ├── 소유권과 차용 검사 ├── 안전 코드와 unsafe 경계 ├── trait 중심 추상화 ├── ResultOption ├── 패턴 일치 └── 데이터 경쟁 방지 타입 규칙 </wiki-tree>

C++와 Rust는 단순히 이전 언어와 대체 언어의 관계로만 볼 수 없다. 기존 C++ 라이브러리와 플랫폼 SDK를 Rust 프로그램에서 사용하거나, C++ 시스템의 새로운 구성 요소를 Rust로 작성하는 혼합 구조도 가능하다.

Wave

Wave (프로그래밍 언어)는 시스템 소프트웨어와 개발 도구, 네트워크 및 하드웨어 인접 코드를 대상으로 개발 중인 정적 타입 프로그래밍 언어다. 공식 프로젝트는 명시적인 동작, 네이티브 출력, 관찰 가능한 컴파일러 단계와 시스템 프로그래밍까지 확장되는 표준 라이브러리를 핵심 방향으로 제시한다.<wiki-footnote>Wave 공식 사이트</wiki-footnote>

Wave와 C++는 다음과 같은 목표 영역을 공유한다.

  • 네이티브 바이너리 생성
  • 시스템 프로그래밍
  • 운영체제 및 하드웨어 접근
  • C ABI와의 연결
  • 명시적인 타입
  • 저수준 메모리 표현
  • 컴파일 시간 최적화
  • 대규모 도구와 라이브러리 구축

그러나 Wave는 C++와의 소스 호환성을 목표로 하는 C++ 방언이 아니다. C++의 기존 문법과 객체 모델을 확장하기보다 별도의 언어 문법, 컴파일러와 표준 라이브러리를 구축하는 독립 언어다.

설계 방향

C++는 C와의 호환성, 수십 년간 누적된 기능과 기존 코드베이스를 유지하며 발전한다. 이러한 호환성은 C++의 생태계와 활용 범위를 지탱하지만 언어 규칙과 구현 복잡성을 증가시키기도 한다.

Wave는 새 언어로서 기존 C++ 소스 호환성을 유지할 필요가 없으므로 다음과 같은 방향을 추구할 수 있다.

  • 숨겨진 동작보다 명시적인 의미
  • 컴파일러 내부 단계의 관찰 가능성
  • 일관된 언어 문법
  • 컴파일러와 패키지 도구의 통합
  • 시스템 API와 C ABI의 직접 지원
  • 새 코드베이스를 전제로 한 타입 규칙

공식 사이트는 Wave의 철학을 “숨겨진 마법보다 명시적인 동작”으로 설명하며, 토큰·AST·IR과 기계어 같은 컴파일 단계를 검사할 수 있는 도구 방향을 제시한다.<wiki-footnote>Wave 공식 사이트: Philosophy</wiki-footnote>

fun main() {
    println("Hello, Wave");
}

C++의 일반적인 진입점은 다음과 같다.

#include <print>

int main()
{
    std::println(
        "Hello, C++"
    );
}

두 언어 모두 명시적인 진입 함수를 사용할 수 있지만 표준 라이브러리, 모듈, 타입과 오류 처리 문법은 서로 다르다.

C ABI

Wave는 C ABI와 시스템 인터페이스를 중요한 상호 운용 경계로 사용한다. 공식 릴리스 문서는 System V ABI 규칙, 구조체 전달과 반환, extern 선언 및 C 표준 라이브러리 바인딩 지원을 설명한다.<wiki-footnote>Wave v0.1.6-pre-beta Release Note</wiki-footnote>

Wave에서 외부 C 함수를 선언하는 개념적 형태는 다음과 같다.

extern "C" {
    fun malloc(
        size: u64
    ) -> ptr<void>;

    fun free(
        memory: ptr<void>
    );
}

C++에서도 C 연결을 선언할 수 있다.

extern "C" {

void* malloc(
    std::size_t size
);

void free(
    void* memory
);

}

실제 Wave 문법은 컴파일러 버전과 언어 설계 진행에 따라 달라질 수 있다. Wave는 공식적으로 현재도 활발한 언어 설계와 컴파일러 개발 단계에 있으므로, 안정된 C++ 국제 표준과 동일한 성숙도의 규격으로 보기는 어렵다.<wiki-footnote>Wave 공식 사이트: Project Status</wiki-footnote>

C++와의 역할 관계

Wave는 C++가 수행해 온 시스템 프로그래밍 역할을 더 작은 새 언어와 통합 도구 체계로 재구성하려는 시도로 볼 수 있다. C++를 바이너리 수준에서 직접 대체하기보다 C ABI와 데이터 형식을 통해 기존 네이티브 생태계와 연결할 수 있다.

<wiki-tree title="C++와 Wave의 관계" state="expanded"> 공통 영역 ├── 시스템 프로그래밍 ├── 네이티브 실행 ├── C ABI ├── 운영체제 인터페이스 ├── 컴파일러와 개발 도구 └── 성능 중심 소프트웨어

C++ ├── C와의 높은 호환성 ├── 국제 표준 ├── 방대한 기존 코드 ├── 다중 패러다임 ├── 템플릿과 클래스 └── 성숙한 플랫폼 생태계

Wave ├── 독립된 새 언어 ├── 명시적 동작 중심 ├── 컴파일러 단계 관찰 ├── 통합 도구 생태계 지향 ├── 새 표준 라이브러리 └── 개발 중인 언어와 구현체 </wiki-tree>

C++와 Wave를 함께 사용하는 시스템에서는 안정적인 C ABI, 파일 형식, IPC나 네트워크 프로토콜을 경계로 둘 수 있다.

다른 시스템 프로그래밍 언어

C++와 유사한 활용 분야를 대상으로 하는 언어는 Rust와 Wave 외에도 여러 가지가 있다.

D

D는 C와 C++ 계열 문법을 사용하면서 가비지 컬렉션, 템플릿, 범위, 계약과 컴파일 시간 기능을 결합한다. C++보다 일관된 문법과 현대적인 기능을 목표로 하지만 C++ 소스 호환 언어는 아니다.

Zig

Zig는 명시적 메모리 할당자, C 상호 운용, 컴파일 시간 실행과 교차 컴파일 도구를 중심으로 하는 시스템 프로그래밍 언어다. 예외와 숨겨진 제어 흐름을 최소화하며 C 라이브러리와의 연결을 중요한 기능으로 삼는다.

Swift

Swift는 Apple 플랫폼의 Objective-C 후속 언어로 시작했지만 값 타입, 프로토콜, 제네릭, 자동 참조 계수와 안전성 기능을 제공한다. Apple은 Swift와 C++ 사이의 직접 상호 운용 기능도 발전시키고 있다.<wiki-footnote>Apple Developer: Mix Swift and C++</wiki-footnote>

Carbon

Carbon은 기존 C++ 코드베이스와의 상호 운용과 점진적 전환을 중요한 목표로 제시한 실험 언어 프로젝트다. C++의 직접 후계나 표준 개정판이 아니라 별도의 언어 설계 프로젝트에 해당한다.

이들 언어는 모두 C++가 강한 영역을 대상으로 하지만 메모리 안전성, 호환성, 런타임, 제네릭과 도구 체계에 서로 다른 우선순위를 둔다.

다른 언어에 미친 영향

C++는 현대 프로그래밍 언어의 문법과 기능에 광범위한 영향을 주었다. 스트롭스트룹은 Java, C#과 여러 스크립트 언어에서 C++의 영향이 명확하며, Ada, COBOL과 Fortran에도 간접적인 영향이 나타났다고 설명했다.<wiki-footnote>Bjarne Stroustrup, Evolving a Language in and for the Real World: C++ 1991–2006</wiki-footnote>

C 계열 문법

C++의 성공은 중괄호와 세미콜론, for, while, if, switch, class, 접근 제한자와 // 주석을 사용하는 문법 계열의 확산에 기여했다.

이와 유사한 표면 문법을 사용하는 언어에는 다음이 포함된다.

  • Java
  • C#
  • JavaScript
  • D
  • PHP
  • Rust
  • Swift의 일부 구문
  • 여러 셰이더 언어
  • 도메인 특화 언어
if (condition) {
    process();
}
else {
    recover();
}

이 문법의 확산은 C만의 영향과 C++의 영향을 완전히 분리하기 어렵다. 그러나 클래스, 접근 제어와 제네릭을 C 계열 구문에 결합한 형태는 C++의 성공 이후 널리 사용됐다.

클래스와 객체 지향 프로그래밍

C++는 클래스 기반 객체 지향 프로그래밍을 주류 시스템과 응용 프로그램 개발에 확산시켰다.

다음 요소가 이후 언어에서 일반화됐다.

  • class 선언
  • public, protected, private
  • 생성자
  • 메서드 오버로딩
  • 상속
  • 가상 또는 재정의 메서드
  • 추상 클래스
  • 인터페이스
  • 연산자 또는 메서드 기반 사용자 정의 타입

Java와 C#은 C++와 비슷한 클래스 문법을 사용하면서 다중 구현 상속을 제한하고 관리형 객체 모델을 채택했다.

추상 클래스와 인터페이스

C++의 순수 가상 함수를 가진 추상 클래스는 구현과 상태 없이 동작 계약을 표현할 수 있다.

class Drawable {
public:
    virtual void draw(
        Renderer& renderer
    ) const = 0;

    virtual ~Drawable() = default;
};

이와 유사한 인터페이스 개념은 Java, C#, COM과 CORBA 등의 구성 요소 모델에서 중요하게 사용됐다. 스트롭스트룹은 COM과 CORBA의 인터페이스 모델이 C++ 추상 클래스에서 유래한 구조라고 평가했다.<wiki-footnote>Bjarne Stroustrup, Evolving a Language in and for the Real World: C++ 1991–2006</wiki-footnote>

제네릭 프로그래밍

C++ 템플릿과 STL은 컨테이너보다 알고리즘과 요구 조건을 중심으로 제네릭 프로그래밍을 구성하는 방식을 널리 알렸다.

template<
    std::input_iterator Iterator,
    class Value
>
Iterator find_value(
    Iterator first,
    Iterator last,
    const Value& value
)
{
    while (first != last) {
        if (*first == value) {
            return first;
        }

        ++first;
    }

    return last;
}

이 접근은 다음과 같은 방향에 영향을 주었다.

  • 정적 타입 제네릭 컨테이너
  • 반복자와 범위
  • 알고리즘과 자료구조 분리
  • 컴파일 시간 다형성
  • 타입 제약
  • 특수화
  • 정책 기반 설계

Java와 C#은 이후 제네릭과 정적 타입 컨테이너를 도입했다. Rust의 trait 기반 제네릭, Swift의 protocol과 generic, 여러 현대 언어의 타입 제약도 서로 다른 형태로 같은 문제를 다룬다.

RAII와 결정적 자원 관리

C++의 RAII는 객체 수명과 자원 수명을 결합한다.

void update()
{
    std::lock_guard lock(
        mutex
    );

    File file(
        "document.txt"
    );

    process(file);
}

범위를 벗어나면 잠금과 파일 자원이 자동으로 해제된다.

이러한 결정적 정리 개념은 다른 언어에서 다음과 같은 형태로 나타난다.

  • Rust의 Drop
  • C#의 usingIDisposable
  • Java의 try-with-resources
  • Python의 with 문과 context manager
  • Swift의 defer
  • Go의 defer

이들 기능이 모두 C++ RAII에서 직접 유래했다고 단정할 수는 없지만, 비메모리 자원은 가비지 컬렉션만으로 관리할 수 없다는 문제와 범위 기반 정리라는 공통된 해법을 공유한다.

값 의미론

C++는 사용자 정의 타입을 기본 타입과 유사한 값으로 사용할 수 있게 했다.

Matrix first;
Matrix second;

Matrix result =
    first * second;

복사, 이동, 비교와 연산자를 사용자 정의할 수 있으며 객체를 컨테이너 안에 직접 저장할 수 있다.

값 의미론은 현대 C++뿐 아니라 Rust의 구조체와 enum, Swift의 struct, C#의 struct와 record, 여러 함수형 언어의 불변 데이터 설계와 연결되는 중요한 개념이다.

영비용 추상화

C++는 높은 수준의 추상화를 사용해도 필요하지 않은 런타임 비용을 강제하지 않는다는 원칙을 널리 알렸다.

template<class T>
constexpr T square(T value)
{
    return value * value;
}

정수 상수에 적용하면 계산이 번역 시점에 끝날 수 있고, 런타임 값에 적용하면 해당 타입의 곱셈으로 직접 변환될 수 있다.

Rust, Zig, D, Swift와 여러 시스템 언어도 네이티브 성능과 고수준 추상화를 함께 제공하는 것을 중요한 목표로 삼는다.

컴파일 시간 프로그래밍

C++ 템플릿 메타프로그래밍은 원래 의도하지 않은 방식으로 번역 시점 계산이 가능하다는 사실이 발견되면서 독립적인 프로그래밍 기법으로 발전했다.

template<int Value>
struct Factorial {
    static constexpr int value =
        Value *
        Factorial<
            Value - 1
        >::value;
};

template<>
struct Factorial<0> {
    static constexpr int value =
        1;
};

현대 C++는 constexpr, consteval, 개념과 일반 함수 문법을 통해 컴파일 시간 계산을 더 직접적으로 지원한다.

consteval int factorial(
    int value
)
{
    int result = 1;

    for (int current = 2;
         current <= value;
         ++current) {
        result *= current;
    }

    return result;
}

컴파일 시간 실행과 타입 수준 계산은 Rust, Zig, D, Swift와 여러 현대 언어에서도 중요한 기능으로 발전했다.

C++가 다른 언어에서 받아들인 영향

C++는 다른 언어에 영향을 주었을 뿐 아니라 여러 언어와 연구 분야에서 개념을 받아들였다.

<wiki-tree title="C++에 들어온 주요 외부 영향" state="expanded"> C (프로그래밍 언어) ├── 기본 문법 ├── 포인터와 배열 ├── 구조체 └── 시스템 프로그래밍 모델

Simula ├── 클래스 ├── 파생 클래스 └── 가상 함수

ALGOL └── 블록 구조와 선언 전통

BCPL └── // 주석 등 일부 문법

함수형 프로그래밍 ├── 람다 ├── 고차 함수 ├── 불변 값 중심 설계 └── 패턴에 가까운 알고리즘 조합

제네릭 프로그래밍 연구 ├── 반복자 ├── 알고리즘 추상화 ├── 개념 └── STL </wiki-tree>

Simula

C++ 클래스와 상속, 가상 함수는 Simula의 영향을 받았다. 스트롭스트룹은 Simula의 클래스가 대규모 프로그램을 구조화하는 데 유용하다고 보았지만, 당시 구현의 성능과 실행 환경은 시스템 프로그래밍 요구에 적합하지 않다고 판단했다. C++는 Simula식 추상화를 C의 실행 모델과 결합하려 했다.

함수형 프로그래밍

현대 C++는 람다와 고차 알고리즘을 제공한다.

std::vector<int> values {
    1,
    2,
    3,
    4
};

auto squares =
    values
    | std::views::transform(
          [](int value) {
              return value * value;
          }
      );

이 코드는 범위, 지연 평가와 함수 객체 조합을 사용한다. 객체 지향 언어로만 인식되던 초기 C++와 달리 현대 C++는 절차적, 객체 지향, 제네릭, 함수형과 데이터 지향 설계를 함께 사용할 수 있다.

언어 간 상호 영향

언어 기능은 한 방향으로만 전파되지 않는다.

<wiki-flow title="프로그래밍 언어 사이의 상호 영향" state="expanded"> 기존 언어에서 새로운 추상화 등장 ↓ 다른 언어가 개념을 수정해 채택 ↓ 새로운 사용 경험과 문제 축적 ↓ 기존 언어가 개선된 형태를 다시 도입 ↓ 여러 언어에서 공통 패턴으로 정착 </wiki-flow>

예를 들어 C++의 템플릿은 Java와 C#의 제네릭에 영향을 주었고, 이후 C++의 개념 설계는 다른 언어의 타입 제약과 프로토콜 경험을 참고하며 발전했다. C++의 람다도 함수형 언어의 개념을 받아들였지만 C++ 객체 수명과 템플릿 모델에 맞는 형태로 설계됐다.

관계의 정리

C++와 다른 언어의 관계는 다음과 같이 정리할 수 있다.

언어C++와의 주요 관계
C직접적인 기반, 공통 ABI와 시스템 생태계
Objective-CC의 별도 객체 지향 확장, Objective-C++로 결합
JavaC++ 계열 문법과 클래스 구조를 관리형 환경으로 재설계
C#C++·Java 계열 문법을 .NET 타입 및 런타임과 결합
Rust같은 시스템 영역에서 소유권과 안전성을 타입에 통합
Wave같은 시스템 영역에서 명시적 동작과 통합 도구 체계를 지향
DC++ 계열 문법과 템플릿을 현대적으로 재구성
Zig명시적 시스템 프로그래밍과 C 상호 운용 중심
SwiftObjective-C 생태계의 후속 언어이자 C++ 상호 운용 확대
PythonC++ 확장 모듈과 네이티브 실행 계층에서 결합

C++는 C의 기계 인접 실행 모델과 Simula의 클래스 추상화를 결합하면서 출발했다. 이후 템플릿, STL, RAII와 값 의미론을 통해 객체 지향 프로그래밍만으로 설명하기 어려운 다중 패러다임 언어가 됐다.

Java와 C#은 C++의 익숙한 문법과 클래스 개념을 관리형 런타임에 맞게 재구성했고, Rust와 Wave 같은 현대 시스템 언어는 C++가 오랫동안 담당해 온 네이티브 시스템 영역을 새로운 안전성 및 도구 설계로 다시 접근한다.

C++의 영향은 특정 문법 요소보다 하드웨어에 가까운 실행 모델과 고수준 추상화를 하나의 언어에서 결합할 수 있다는 관점에 더 크게 나타난다. 이 관점은 이후 시스템 프로그래밍 언어가 성능, 안전성, 표현력과 상호 운용 사이에서 어떤 균형을 선택할지를 결정하는 중요한 기준이 되었다.

장점과 한계

C++는 하드웨어와 운영체제에 직접 접근할 수 있는 저수준 기능과 클래스, 템플릿, 범위, 알고리즘과 같은 고수준 추상화를 하나의 언어에서 제공한다. 이러한 성격 덕분에 운영체제 구성 요소, 게임 엔진, 브라우저, 데이터베이스, 임베디드 시스템, 금융 거래 시스템과 과학 계산처럼 성능과 자원 제어가 중요한 분야에서 사용된다.

C++의 주요 설계 목표에는 하드웨어에 대한 직접적인 접근, 높은 실행 효율, 정적 타입 안전성, 자원 안전성, 이식성과 영비용 추상화가 포함된다.<wiki-footnote>Bjarne Stroustrup, 21st Century C++</wiki-footnote> 그러나 이러한 목표가 모든 C++ 프로그램에서 자동으로 달성되는 것은 아니다. 언어는 안전한 추상화와 저수준 조작을 모두 허용하므로 실제 품질은 사용하는 기능과 설계 방식, 도구 체계와 검증 절차에 크게 좌우된다.

높은 실행 성능

C++는 일반적으로 대상 플랫폼의 네이티브 기계어로 컴파일된다. 컴파일러는 함수 인라인화, 상수 전파, 벡터화, 탈가상화, 죽은 코드 제거와 링크 시간 최적화 등을 적용할 수 있다.

template<class T>
constexpr T square(T value)
{
    return value * value;
}

int calculate(int value)
{
    return square(value);
}

최적화된 빌드에서는 square()의 호출 경계가 제거되고 정수 곱셈만 남을 수 있다.

C++ 프로그램은 다음 요소를 직접 통제할 수 있다.

  • 객체의 저장 위치와 메모리 배치
  • 동적 할당의 사용 여부
  • 데이터 정렬
  • 복사와 이동
  • 가상 디스패치의 사용 여부
  • 스레드와 동기화
  • SIMD 및 가속기 명령
  • 시스템 호출
  • 정적·공유 라이브러리 연결

이러한 제어는 높은 처리량, 낮은 지연 시간과 제한된 메모리 사용량이 중요한 프로그램에서 유리하다. C++의 성능 관련 기술 보고서도 실행 속도뿐 아니라 프로그램 크기와 메모리 사용량을 언어 설계의 중요한 효율성 문제로 다룬다.<wiki-footnote>ISO/IEC TR 18015, Technical Report on C++ Performance</wiki-footnote>

그러나 C++로 작성했다는 사실만으로 프로그램이 빠르다고 보장되지는 않는다. 자료구조 선택, 메모리 접근 패턴, 불필요한 할당, 캐시 적중률과 병렬화 방식이 실제 성능을 결정한다.

for (const Item& item : items) {
    auto temporary =
        std::make_unique<Temporary>(
            item
        );

    process(*temporary);
}

동적 수명이 필요하지 않다면 값 객체로 작성할 수 있다.

for (const Item& item : items) {
    Temporary temporary(item);

    process(temporary);
}

성능 개선은 추측보다 프로파일링과 반복 측정을 통해 검증해야 한다.

영비용 추상화

C++는 고수준 추상화가 동일한 기능을 직접 작성한 코드보다 불필요한 시간·공간 비용을 강제하지 않도록 설계됐다. 사용하지 않는 기능의 비용을 지불하지 않고, 사용하는 추상화는 신중하게 작성한 저수준 코드와 비슷한 효율을 낼 수 있어야 한다는 원칙이다.<wiki-footnote>Bjarne Stroustrup, C++ — an Invisible Foundation of Everything</wiki-footnote>

template<
    std::input_iterator Iterator,
    class Value
>
Iterator find_value(
    Iterator first,
    Iterator last,
    const Value& value
)
{
    while (first != last) {
        if (*first == value) {
            return first;
        }

        ++first;
    }

    return last;
}

이 함수는 배열, 표준 컨테이너와 사용자 정의 반복자에 공통으로 적용되지만, 각 타입에 맞게 번역 시점에 인스턴스화된다.

영비용 추상화에 사용되는 대표적인 기능은 다음과 같다.

  • 값 타입
  • 인라인 함수
  • 템플릿
  • 개념
  • constexpr
  • 범위와 알고리즘
  • 정적 다형성
  • 이동 의미론
  • 사용자 정의 할당자

영비용이라는 표현이 모든 기능이 실제로 무료라는 뜻은 아니다. 가상 함수, 동적 메모리 할당, 참조 계수, 타입 소거와 동기화에는 실행 비용이 있다. C++의 장점은 이러한 비용이 발생하는 기능을 프로그램의 요구에 따라 선택할 수 있다는 데 있다.

자원 수명의 명확한 관리

C++는 객체의 소멸 시점을 언어 규칙으로 결정한다. 파일, 잠금, 소켓, 메모리, 그래픽스 자원과 트랜잭션을 객체 수명에 연결하는 RAII를 사용할 수 있다.

void update_document()
{
    std::lock_guard lock(
        document_mutex
    );

    std::ofstream file(
        "document.txt"
    );

    file << document.serialize();
}

함수가 정상적으로 반환되거나 예외로 빠져나가도 지역 객체의 소멸자가 실행된다. 잠금은 해제되고 파일은 닫힌다.

C++ Core Guidelines는 자원 관리를 객체 수명에 연결하고, 명시적인 newdelete보다 자동 객체와 소유권 타입을 사용하도록 권장한다.<wiki-footnote>C++ Core Guidelines</wiki-footnote>

다음과 같은 자원을 같은 방식으로 관리할 수 있다.

  • 힙 메모리
  • 파일과 디렉터리 핸들
  • 뮤텍스 잠금
  • 네트워크 연결
  • GPU 버퍼와 텍스처
  • 운영체제 객체
  • 데이터베이스 트랜잭션
  • 임시 상태 변경
class Transaction {
    Database& database_;
    bool committed_ = false;

public:
    explicit Transaction(
        Database& database
    )
        : database_(database)
    {
        database_.begin();
    }

    void commit()
    {
        database_.commit();
        committed_ = true;
    }

    ~Transaction()
    {
        if (!committed_) {
            database_.rollback();
        }
    }
};

자원 관리가 자동화되더라도 객체의 소유 관계가 불명확하면 순환 참조, 유효하지 않은 참조와 잘못된 파괴 순서가 발생할 수 있다. RAII는 올바른 소유 구조와 함께 사용해야 한다.

다양한 메모리 관리 방식

C++는 자동 저장 기간 객체, 정적 객체, 동적 할당, 메모리 풀, 영역 할당자와 사용자 정의 할당자를 함께 사용할 수 있다.

std::array<Entity, 1024>
    fixed_entities;

std::vector<Entity>
    dynamic_entities;

auto resource =
    std::make_unique<Resource>();

std::pmr::monotonic_buffer_resource
    frame_memory;

이러한 선택지는 임베디드 시스템, 실시간 소프트웨어, 게임 엔진과 고성능 서버처럼 일반적인 가비지 컬렉터가 적합하지 않은 환경에서 유용하다.

반면 메모리 관리 방식이 다양하다는 것은 소유권과 수명을 잘못 표현할 가능성도 크다는 뜻이다. 원시 포인터, 참조, 컨테이너 반복자와 비소유 뷰는 대상 객체보다 오래 살아서는 안 된다.

std::string_view create_view()
{
    std::string text =
        "temporary";

    return text;
}

반환된 std::string_view는 이미 파괴된 문자열을 가리킨다. C++ 표준에서 객체와 참조의 수명은 프로그램 실행 중의 속성이며, 객체 수명이 끝난 뒤에는 해당 객체를 사용하는 데 큰 제약이 적용된다.<wiki-footnote>C++ Working Draft: Object lifetime</wiki-footnote>

하드웨어와 운영체제에 대한 직접 접근

C++는 포인터, 비트 연산, 정렬, 원자적 연산과 구현체 내장 함수를 통해 하드웨어와 가까운 코드를 작성할 수 있다.

class Register32 {
    volatile std::uint32_t* address_;

public:
    explicit Register32(
        std::uintptr_t address
    ) noexcept
        : address_(
              reinterpret_cast<
                  volatile std::uint32_t*
              >(address)
          )
    {
    }

    void write(
        std::uint32_t value
    ) noexcept
    {
        *address_ = value;
    }
};

이러한 기능은 다음 영역에서 필요하다.

  • 운영체제
  • 펌웨어
  • 장치 제어
  • 그래픽스 API
  • SIMD
  • 네트워크 패킷 처리
  • 메모리 매핑 파일
  • 사용자 정의 런타임
  • 가속기 프로그래밍

저수준 접근은 강력하지만, 잘못된 주소, 정렬 위반, 데이터 경쟁과 객체 수명 위반은 미정의 동작을 일으킬 수 있다.

다양한 프로그래밍 패러다임

C++는 절차적, 객체 지향, 제네릭, 함수형, 데이터 지향과 메타프로그래밍 방식을 함께 지원한다.

struct Particle {
    Vector3 position;
    Vector3 velocity;
};

void integrate(
    std::span<Particle> particles,
    float delta_time
)
{
    std::ranges::for_each(
        particles,
        [delta_time](
            Particle& particle
        ) {
            particle.position +=
                particle.velocity *
                delta_time;
        }
    );
}

이 코드에는 값 타입, 절차적 함수, 범위, 람다와 제네릭 알고리즘이 함께 사용된다.

하나의 방법론에 모든 문제를 맞출 필요가 없다는 점은 대형 시스템에서 유용하다. 반면 같은 기능을 상속, 템플릿, 함수 포인터, std::variant, 타입 소거와 매크로 등 여러 방식으로 구현할 수 있어 팀 내부의 설계 일관성이 떨어질 수 있다.

프로젝트는 다음과 같은 기준을 정할 수 있다.

  • 값 타입을 기본으로 사용
  • 런타임 교체가 필요할 때만 가상 함수 사용
  • 컴파일 시간 다형성에는 템플릿과 개념 사용
  • 닫힌 타입 집합에는 std::variant 고려
  • 자원 소유권은 타입으로 표현
  • 매크로보다 함수와 상수 사용

정적 타입 체계

C++의 정적 타입 검사는 함수 호출, 오버로드, 템플릿 인스턴스화와 사용자 정의 타입의 잘못된 조합을 번역 시점에 발견할 수 있다.

struct Meter {
    double value;
};

struct Second {
    double value;
};

Meter distance {
    100.0
};

Second duration {
    10.0
};

// Meter result = distance + duration;

단순한 double 대신 별도 타입을 사용하면 서로 다른 단위의 혼합을 막을 수 있다.

template<class T>
concept Drawable =
    requires(
        const T& value,
        Renderer& renderer
    ) {
        value.draw(renderer);
    };

template<Drawable T>
void render(
    const T& value,
    Renderer& renderer
)
{
    value.draw(renderer);
}

개념을 이용하면 템플릿이 요구하는 연산을 인터페이스로 표현할 수 있다.

그러나 C++에는 암시적 변환, 포인터 변환, 정수 승격과 C 호환 규칙이 존재한다. 타입이 존재한다는 사실만으로 모든 잘못된 값 변환을 막지는 못한다.

int convert(double value)
{
    return value;
}

컴파일러 경고, 리스트 초기화, 강한 타입과 정적 분석을 함께 사용할 수 있다.

이식성과 넓은 플랫폼 지원

C++는 ISO 표준으로 규정되며 GCC, Clang, Microsoft Visual C++와 여러 플랫폼 전용 구현체가 존재한다. 데스크톱, 서버, 모바일, 게임 콘솔, 임베디드 장치, GPU와 WebAssembly 환경에서 사용할 수 있다.

표준 라이브러리는 문자열, 컨테이너, 알고리즘, 스레드, 파일 시스템과 시간 처리를 위한 공통 인터페이스를 제공한다.

#include <filesystem>

void list_files(
    const std::filesystem::path&
        directory
)
{
    for (const auto& entry :
         std::filesystem::
             directory_iterator(
                 directory
             )) {
        process(entry.path());
    }
}

같은 소스를 여러 운영체제에서 사용할 수 있지만 완전한 이식성이 자동으로 제공되는 것은 아니다. 다음 요소는 플랫폼별 처리가 필요할 수 있다.

  • 창과 입력
  • 동적 라이브러리
  • 프로세스 관리
  • 장치 접근
  • 파일 권한
  • 문자 인코딩
  • 네트워크 설정
  • 컴파일러 확장
  • 구조체 ABI
  • CPU 명령 집합

표준 C++와 플랫폼 계층을 분리하면 이식성을 높일 수 있다.

기존 코드와 라이브러리 생태계

C++는 수십 년 동안 사용됐으며 대규모 기존 코드베이스와 라이브러리를 보유한다.

대표적인 생태계는 다음과 같다.

  • 운영체제와 플랫폼 SDK
  • 게임 엔진
  • 그래픽스 API
  • 컴파일러 인프라
  • GUI 프레임워크
  • 과학 계산
  • 컴퓨터 비전
  • 네트워크
  • 데이터베이스
  • 로봇
  • 머신러닝 런타임
  • 미디어 처리

C ABI를 통해 C 라이브러리와 운영체제 인터페이스를 직접 사용할 수 있고, Python, Java, C#, Rust와 다른 언어에 네이티브 기능을 제공할 수도 있다.

extern "C" {

struct ImageProcessor;

ImageProcessor*
image_processor_create();

void image_processor_destroy(
    ImageProcessor* processor
);

int image_processor_run(
    ImageProcessor* processor,
    const ImageView* input,
    ImageView* output
);

}

오래된 생태계는 장점인 동시에 제약이다. 프로젝트에는 다음 코드가 함께 존재할 수 있다.

  • C 스타일 메모리 관리
  • 오래된 전처리기 매크로
  • C++98 관용구
  • 독자적인 스마트 포인터
  • 현대 표준 라이브러리
  • 코루틴과 범위
  • 플랫폼별 확장

기존 코드를 현대화할 때에는 전체 재작성보다 모듈 경계와 소유권을 점진적으로 개선하는 방식이 현실적일 수 있다.

긴 하위 호환성

C++는 오래된 소스와 라이브러리를 계속 사용할 수 있도록 높은 수준의 하위 호환성을 유지한다. 이 특성은 장기간 운영되는 제품과 산업용 시스템에 유리하다.

그러나 오래된 기능과 새로운 기능이 함께 남으면서 언어 규모가 커지고 규칙 사이의 상호작용이 복잡해졌다.

예를 들어 문자열을 나타내는 방법만 해도 다음처럼 다양하다.

  • char*
  • const char*
  • 문자 배열
  • std::string
  • std::string_view
  • UTF 문자 타입과 문자열
  • 플랫폼 전용 문자열
  • 외부 라이브러리 문자열

각 타입은 소유권, 인코딩, 널 종료와 수명 규칙이 다르다.

하위 호환성은 기존 투자를 보호하지만 새 사용자가 언어의 역사적 층위를 함께 학습해야 하는 부담을 만든다.

큰 언어 규모와 학습 난도

C++는 기본 문법만으로 작은 프로그램을 작성할 수 있지만 언어 전체를 이해하려면 많은 개념이 필요하다.

  • 값 범주
  • 객체 수명
  • 초기화 형식
  • 오버로드 결정
  • 암시적 변환
  • 템플릿 인스턴스화
  • 종속 이름
  • 예외 안전성
  • 메모리 모델
  • 단일 정의 규칙
  • 모듈과 번역 단위
  • ABI
  • 미정의 동작

같은 기호도 문맥에 따라 다른 의미를 가진다.

int* pointer;
int& reference = value;

template<class T>
T&& forward_reference(T&& value);

auto lambda =
    [&value] {
        use(value);
    };

&는 참조 선언, 주소 연산과 람다 캡처에서 서로 다르게 사용된다.

초급자가 언어의 모든 기능을 먼저 배워야 하는 것은 아니다. 현대 C++의 제한된 부분집합부터 시작할 수 있지만 기존 코드와 오류 메시지를 이해하려면 점차 더 넓은 규칙을 익혀야 한다.

복잡한 초기화와 객체 수명

C++는 여러 초기화 형식을 제공한다.

Widget first;
Widget second();
Widget third {};
Widget fourth(42);
Widget fifth { 42 };
Widget sixth = Widget(42);
Widget seventh = { 42 };

이들 형식은 기본 초기화, 함수 선언 해석, 직접 초기화, 리스트 초기화와 복사 초기화처럼 서로 다른 규칙을 적용받을 수 있다.

현대 C++에서는 중괄호 초기화, 팩토리 함수와 명확한 생성자 인터페이스를 사용해 혼동을 줄일 수 있다.

Widget widget {
    WidgetOptions {
        .width = 1280,
        .height = 720,
        .visible = true
    }
};

객체 수명도 포인터와 참조, 공용체, 배치 생성, 임시 객체와 결합하면 복잡해진다. 표준은 객체 수명의 시작과 종료, 수명 밖의 저장 공간 사용을 세밀하게 규정한다.<wiki-footnote>C++ Working Draft: Object lifetime</wiki-footnote>

일반 애플리케이션에서는 원시 저장 공간 조작을 라이브러리 내부에 제한하고 값 타입과 표준 컨테이너를 사용하는 편이 안전하다.

메모리 안전성 문제

C++는 범위를 벗어난 접근, 해제 후 사용, 이중 해제와 유효하지 않은 포인터 역참조를 타입 체계에서 완전히 막지 않는다.

int* pointer =
    new int(42);

delete pointer;

int value =
    *pointer;
std::array<int, 3> values {
    1,
    2,
    3
};

int value =
    values[3];

이러한 코드는 미정의 동작을 일으킨다.

위험을 줄이는 방법은 다음과 같다.

  • 표준 컨테이너 사용
  • 스마트 포인터 사용
  • 소유권과 비소유 참조 구분
  • std::spanstd::string_view의 수명 관리
  • 범위 기반 반복
  • RAII
  • 정적 분석
  • AddressSanitizer
  • UndefinedBehaviorSanitizer
  • fuzzing
  • 코드 리뷰

C++ Core Guidelines는 현대적인 언어 기능과 정적 분석을 통해 더 단순하고 안전하며 유지보수 가능한 C++를 작성하는 것을 목표로 한다.<wiki-footnote>C++ Core Guidelines</wiki-footnote>

그러나 이러한 규칙은 언어가 자동으로 강제하는 보장과 동일하지 않다. 팀의 코딩 규칙과 도구 설정이 일관되지 않으면 안전하지 않은 코드가 계속 포함될 수 있다.

미정의 동작

C++ 표준은 일부 잘못된 프로그램에 의미를 부여하지 않는다. 이를 미정의 동작이라고 한다.

대표적인 예는 다음과 같다.

  • 배열 범위 초과
  • 유효하지 않은 포인터 역참조
  • 부호 있는 정수 오버플로
  • 객체 수명 위반
  • 데이터 경쟁
  • 잘못된 시프트
  • 정렬되지 않은 접근
  • 해제 후 사용

미정의 동작이 발생한 프로그램은 예측할 수 없는 값을 반환하는 데 그치지 않는다. 구현체는 번역을 중단하거나 실행을 종료할 수 있으며, 진단 없이 예상과 전혀 다른 동작을 만들 수도 있다.<wiki-footnote>C++ Working Draft: Undefined behavior</wiki-footnote>

int increase(int value)
{
    return value + 1;
}

컴파일러는 정의된 프로그램에서는 부호 있는 정수 오버플로가 발생하지 않는다고 가정할 수 있다.

멀티스레드 코드의 데이터 경쟁도 미정의 동작이다.<wiki-footnote>C++ Working Draft: Data races</wiki-footnote>

int counter = 0;

void increment()
{
    ++counter;
}

여러 스레드가 동기화 없이 동시에 호출하면 데이터 경쟁이 발생할 수 있다.

미정의 동작은 C++가 하드웨어와 가까운 최적화를 가능하게 하는 기반이기도 하지만, 프로그램의 안전성과 이식성을 어렵게 만드는 주요 원인이다.

오류 메시지와 템플릿 복잡성

템플릿과 개념은 강력한 추상화를 제공하지만 잘못 사용하면 긴 오류 메시지와 높은 컴파일 비용을 만들 수 있다.

std::vector<std::string> values;

std::ranges::sort(
    values,
    [](int left, int right) {
        return left < right;
    }
);

비교 함수의 매개변수 타입이 원소 타입과 맞지 않는다. 구현체는 제약 조건과 오버로드 후보를 포함한 긴 진단을 출력할 수 있다.

개념을 사용하면 요구 사항을 더 가까운 위치에서 검사할 수 있다.

template<class Compare>
concept StringCompare =
    std::predicate<
        Compare,
        const std::string&,
        const std::string&
    >;

그래도 대형 제네릭 라이브러리의 진단은 일반 함수 호출 오류보다 복잡할 수 있다.

긴 컴파일 시간

C++는 각 번역 단위에서 헤더를 전처리하고, 템플릿을 파싱하고 인스턴스화한다. 대형 헤더와 제네릭 라이브러리가 반복 포함되면 빌드 시간이 증가한다.

#include <algorithm>
#include <filesystem>
#include <format>
#include <ranges>
#include <unordered_map>
#include <vector>

수백 개의 번역 단위가 같은 헤더를 포함하면 동일한 선언을 반복 분석할 수 있다.

빌드 시간을 줄이는 방법은 다음과 같다.

  • 공개 헤더 의존성 축소
  • 전방 선언
  • 구현을 소스 파일로 이동
  • 명시적 템플릿 인스턴스화
  • 전처리 헤더
  • 통합 빌드
  • C++ 모듈
  • 컴파일 캐시
  • 분산 빌드
  • 빠른 링커
  • 증분 빌드 구조 개선

C++20 모듈은 선언을 텍스트로 반복 포함하는 문제를 줄이기 위해 도입됐지만, 도구 체계와 외부 라이브러리의 지원 상태가 균일하지 않을 수 있다.

복잡한 빌드와 의존성 관리

C++ 표준은 공식 빌드 시스템이나 패키지 관리자를 지정하지 않는다. 프로젝트는 CMake, Meson, Bazel, MSBuild와 같은 도구를 선택하고 Conan, vcpkg, 운영체제 패키지나 직접 포함 방식으로 의존성을 관리한다.

외부 라이브러리는 다음 설정에 따라 서로 다른 바이너리가 필요할 수 있다.

  • 운영체제
  • CPU 아키텍처
  • 컴파일러
  • 표준 라이브러리
  • C++ ABI
  • 디버그·릴리스 런타임
  • 정적·공유 연결
  • 예외와 RTTI 사용 여부
  • 구조체 패킹
  • 프로젝트별 매크로

같은 라이브러리 버전이라도 빌드 설정이 다르면 호환되지 않을 수 있다.

target_compile_features(
    application
    PRIVATE
        cxx_std_23
)

target_link_libraries(
    application
    PRIVATE
        graphics
        audio
        network
)

의존성 버전과 도구 체계를 명시하지 않으면 개발자 환경과 CI, 배포 환경에서 서로 다른 결과가 생길 수 있다.

ABI 호환성

ISO C++ 표준은 하나의 공통 바이너리 ABI를 규정하지 않는다. 함수 호출 규약, 클래스 배치, 이름 장식, 예외 처리와 표준 라이브러리 타입 표현은 구현체와 플랫폼에 따라 달라질 수 있다.

class Plugin {
public:
    virtual std::vector<
        std::string
    > names() const = 0;

    virtual ~Plugin() = default;
};

이 인터페이스를 공유 라이브러리 경계에 노출하면 다음 요소의 호환성이 필요하다.

  • 컴파일러 ABI
  • 표준 라이브러리 구현체
  • std::stringstd::vector ABI
  • 런타임 라이브러리
  • 메모리 할당자
  • 예외 처리
  • 디버그 반복자 설정

장기간 유지되는 플러그인이나 언어 간 경계에서는 C ABI, 불투명 포인터와 명시적인 버전 구조를 사용할 수 있다.

extern "C" {

struct PluginHandle;

struct PluginApi {
    std::uint32_t version;

    void (*update)(
        PluginHandle* plugin,
        double delta_time
    );

    void (*destroy)(
        PluginHandle* plugin
    );
};

}

표준 지원의 불균일성

새 C++ 표준이 발행돼도 모든 구현체가 핵심 언어와 표준 라이브러리를 동시에 완성하는 것은 아니다.

프로젝트는 다음을 따로 확인해야 한다.

  • 핵심 언어 기능
  • 표준 라이브러리 기능
  • 컴파일러 버전
  • 표준 라이브러리 버전
  • 운영체제 런타임
  • IDE와 빌드 시스템
  • 모듈 및 도구 지원
#include <version>

#if defined(__cpp_lib_expected)

#include <expected>

#else

#error std::expected support is required

#endif

기능 검사 매크로와 여러 컴파일러를 사용하는 CI를 통해 지원 범위를 관리할 수 있다.

예외 처리의 장단점

예외는 오류를 일반 반환값과 분리하고, 여러 호출 계층을 따라 자동으로 전달하며, 스택 되감기 중 RAII 객체를 정리한다.

Document load_document(
    const Path& path
)
{
    File file(path);

    if (!file) {
        throw FileError(path);
    }

    return parse(file);
}

그러나 예외에는 다음과 같은 고려 사항이 있다.

  • 보이지 않는 제어 흐름
  • 예외 안전성 설계
  • 바이너리 크기
  • 일부 환경의 런타임 비용
  • C ABI와의 불일치
  • 예외를 비활성화한 플랫폼
  • 실시간 시스템의 예측 가능성

임베디드, 게임 엔진과 일부 서버 프로젝트는 예외를 제한하고 오류 코드나 std::expected를 사용할 수 있다.

std::expected<
    Document,
    FileError
>
load_document(
    const Path& path
);

예외와 오류 값 가운데 어느 방식이 더 적합한지는 실패 빈도, 호출 구조, 실행 환경과 프로젝트 규칙에 따라 달라진다.

동시성의 복잡성

C++는 스레드, 뮤텍스, 원자적 연산, 조건 변수와 메모리 순서를 표준으로 제공한다. 이를 통해 운영체제에 독립적인 동시성 코드를 작성할 수 있다.

class Counter {
    std::mutex mutex_;
    int value_ = 0;

public:
    void increment()
    {
        std::lock_guard lock(
            mutex_
        );

        ++value_;
    }
};

그러나 공유 메모리 동시성은 다음 문제를 일으킬 수 있다.

  • 데이터 경쟁
  • 교착 상태
  • 라이브락
  • 기아
  • false sharing
  • 메모리 순서 오류
  • 객체 수명 문제
  • 재현하기 어려운 간헐적 오류

원자적 연산과 약한 메모리 순서는 고성능 동시성 구조에 필요할 수 있지만 검증이 어렵다.

ready.store(
    true,
    std::memory_order_release
);

while (!ready.load(
           std::memory_order_acquire
       )) {
}

메시지 전달, 불변 데이터, 작업 큐와 상위 수준 실행 모델을 사용하면 공유 상태를 줄일 수 있다.

안전한 C++와 기존 C++의 차이

현대 C++에서는 다음과 같은 방식이 일반적으로 권장된다.

기존 방식현대적인 대안
원시 newdelete값 타입, std::unique_ptr, 컨테이너
C 배열과 크기 분리std::array, std::vector, std::span
널 포인터 상수 0nullptr
C 스타일 캐스트C++ 명시적 캐스트
매크로 상수constexpr
수동 잠금 해제std::lock_guard, std::unique_lock
출력 매개변수 중심 오류반환값, std::optional, std::expected
소유권 불명 포인터소유·비소유 인터페이스 구분
std::optional<User>
find_user(UserId id);

void process(
    std::span<const Item> items
);

std::unique_ptr<Resource>
create_resource();

그러나 C++ 언어는 기존 방식을 대부분 계속 허용한다. 따라서 현대적이고 안전한 부분집합을 사용하는 프로젝트와 오래된 관용구를 유지하는 프로젝트의 코드 품질 차이가 크게 나타날 수 있다.

유지보수성

C++는 강한 타입, 캡슐화, 모듈화와 자동 자원 관리를 통해 대규모 시스템을 구조화할 수 있다.

class Renderer {
public:
    virtual Texture
    create_texture(
        const TextureDescription&
            description
    ) = 0;

    virtual void submit(
        std::span<
            const DrawCommand
        > commands
    ) = 0;

    virtual ~Renderer() = default;
};

공개 인터페이스를 작게 유지하고 구현 세부 사항을 숨기면 백엔드를 교체할 수 있다.

반면 템플릿과 상속, 매크로, 전역 상태와 조건부 컴파일을 과도하게 결합하면 코드를 이해하기 어려워진다.

template<
    class Policy,
    class Allocator,
    class Traits,
    bool ThreadSafe,
    std::size_t InlineCapacity
>
class Container;

모든 옵션을 하나의 템플릿에 넣는 것보다 실제 변화 지점을 분리하고 합성 가능한 작은 구성 요소를 사용할 수 있다.

적합한 사용 조건

C++는 다음 조건이 여러 개 동시에 필요한 프로젝트에서 강점을 가진다.

  • 높은 CPU 성능
  • 낮거나 예측 가능한 지연 시간
  • 제한된 메모리
  • 하드웨어와 운영체제 API 접근
  • 네이티브 바이너리 배포
  • 복잡한 자원 수명
  • 여러 플랫폼 지원
  • 기존 C·C++ 라이브러리 통합
  • 긴 제품 수명
  • 사용자 정의 메모리 배치
  • 정적 타입 기반 대규모 추상화

대표적인 분야는 다음과 같다.

  • 게임 엔진
  • 운영체제와 시스템 도구
  • 임베디드 시스템
  • 브라우저
  • 데이터베이스
  • 컴파일러
  • 로봇과 자율 시스템
  • 금융 거래 시스템
  • 그래픽스와 미디어
  • 과학 계산
  • 머신러닝 런타임

다른 언어가 더 적합할 수 있는 경우

다음과 같은 작업에서는 C++의 제어 능력보다 개발 속도와 단순한 배포가 더 중요할 수 있다.

  • 짧은 자동화 스크립트
  • 간단한 데이터 분석
  • 빠른 프로토타입
  • 일반적인 CRUD 웹 서비스
  • 브라우저 중심 사용자 인터페이스
  • 가비지 컬렉션이 적합한 업무 프로그램
  • 런타임 반사와 동적 코드 로딩이 핵심인 시스템
  • 네이티브 성능이 중요하지 않은 소규모 도구
  • 개발 인력이 C++ 도구 체계에 익숙하지 않은 프로젝트

이 경우 Python, JavaScript, Java, C#, Go, Kotlin이나 다른 언어가 더 적은 개발 비용으로 요구 사항을 만족할 수 있다.

하나의 시스템 안에서 언어를 역할별로 나눌 수도 있다.

사용자 인터페이스: JavaScript 또는 C#
업무 로직: Java, Go 또는 Python
네이티브 핵심: C++
GPU 연산: CUDA 또는 셰이더 언어
자동화와 빌드 도구: Python과 셸

C++를 사용할지 여부는 언어의 절대적인 우열보다 성능, 안전성, 인력, 플랫폼, 개발 기간과 유지보수 비용을 함께 고려해 결정해야 한다.

종합 평가

관점장점한계
성능네이티브 코드와 세밀한 최적화비효율적인 설계를 자동으로 해결하지 않음
추상화값 타입, 템플릿, 개념과 다형성기능 조합과 오류 메시지가 복잡할 수 있음
자원 관리RAII와 결정적 소멸소유권을 잘못 설계하면 수명 오류 발생
메모리배치와 할당 방식을 직접 선택범위 초과와 해제 후 사용 가능
이식성표준과 다수 구현체플랫폼 API와 ABI 차이는 별도 관리
생태계방대한 라이브러리와 기존 코드오래된 관용구와 도구가 혼재
빌드다양한 플랫폼과 구성 지원컴파일 시간과 의존성 관리가 복잡
동시성표준 스레드와 원자적 연산데이터 경쟁과 메모리 순서 검증이 어려움
안전성강한 타입과 안전한 현대 관용구안전한 부분집합 사용이 자동으로 강제되지 않음
유지보수대규모 시스템 구성 가능언어 규모와 하위 호환성이 학습 비용 증가

C++의 핵심 장점은 낮은 수준의 자원 제어를 포기하지 않으면서 높은 수준의 추상화를 작성할 수 있다는 점이다. 반면 동일한 자유는 메모리 안전성, 언어 복잡성, 빌드 구성과 ABI 관리에 대한 책임을 개발자와 프로젝트에 남긴다.

현대적인 C++ 프로젝트는 값 타입, RAII, 표준 컨테이너, 스마트 포인터, 범위, 개념과 명확한 소유권 인터페이스를 중심으로 작성할 수 있다. 여기에 높은 경고 수준, 정적 분석, sanitizer, fuzzing과 여러 구현체를 사용하는 지속적 통합을 결합하면 오래된 C 스타일의 위험을 상당 부분 줄일 수 있다.

C++는 모든 프로그램에 가장 단순한 선택은 아니지만, 성능, 플랫폼 접근, 자원 제어와 대규모 추상화가 동시에 필요한 분야에서는 여전히 강력한 선택지다.

관련 문서

  1. Bjarne Stroustrup FAQ: When was C++ invented?
  2. Standard C++: The Standard
  3. ISO/IEC 14882:2024 — Programming languages — C++
  4. Bjarne Stroustrup, A History of C++: 1979–1991
  5. cppreference: History of C++
  6. Bjarne Stroustrup, A History of C++: Run-time Efficiency
  7. Bjarne Stroustrup, A conversation with Bjarne Stroustrup
  8. Bjarne Stroustrup FAQ: When was C++ invented?
  9. C++: The Making of a Standard
  10. Standard C++: The Committee
  11. Standard C++ FAQ: What is C++0x?
  12. cppreference: C++17
  13. cppreference: C++20
  14. Standard C++: The Standard
  15. Standard C++: Current Status
  16. Standard C++ FAQ: What is the zero-overhead principle?
  17. C++ Working Draft: Abstract machine
  18. C++ Working Draft: Introduction and compliance
  19. C++ Working Draft: Phases of translation
  20. C++ Working Draft: Declarations and definitions
  21. C++ Working Draft: Inline specifier
  22. C++ Working Draft: Object model
  23. C++ Working Draft: Storage duration
  24. C++ Working Draft: Object lifetime
  25. C++ Working Draft: Value categories
  26. C++ Working Draft: Undefined behavior
  27. C++ Working Draft: Types
  28. C++ Working Draft: Fundamental types
  29. C++ Working Draft: Narrow character types
  30. C++ Working Draft: Compound types
  31. C++ Working Draft: CV-qualifiers
  32. C++ Working Draft: Standard conversions
  33. C++ Working Draft: Template argument deduction
  34. C++ Working Draft: Concepts library
  35. C++ Working Draft: Type traits
  36. C++ Working Draft: Expressions
  37. C++ Working Draft: Declarations
  38. C++ Working Draft: Identifiers
  39. C++ Working Draft: Lexical conventions
  40. C++ Working Draft: Expressions
  41. C++ Working Draft: Statements
  42. C++ Working Draft: Functions
  43. C++ Working Draft: Overload resolution
  44. C++ Working Draft: Name lookup
  45. C++ Working Draft: Classes
  46. C++ Working Draft: Constructors
  47. C++ Working Draft: Overloaded operators
  48. C++ Working Draft: Exception handling
  49. Bjarne Stroustrup, What were your major original design goals for C++?
  50. Bjarne Stroustrup, Speaking C++ as a Native
  51. Bjarne Stroustrup, Abstraction and the C++ Machine Model
  52. Standard C++ FAQ: Generic Programming
  53. cppreference: Constraints and concepts
  54. cppreference: Lambda expressions
  55. cppreference: Ranges library
  56. cppreference: constexpr specifier
  57. Bjarne Stroustrup, Foundations of C++
  58. Standard C++ FAQ: The zero-overhead principle
  59. Bjarne Stroustrup, C++'s Model for Type- and Resource-Safety
  60. cppreference: RAII
  61. C++ Working Draft: Memory and objects
  62. C++ Working Draft: Object lifetime
  63. C++ Working Draft: Storage duration
  64. C++ Working Draft: New expressions
  65. cppreference: std::malloc
  66. cppreference: std::lock_guard
  67. cppreference: std::enable_shared_from_this
  68. cppreference: Memory management library
  69. C++ Core Guidelines: Resource management
  70. Bjarne Stroustrup, Exception Safety: Concepts and Techniques
  71. C++ Working Draft: Templates
  72. WG21 N4191: Fold Expressions
  73. C++ Working Draft: Deducing template arguments from a function call
  74. C++ Working Draft: Constraints and concepts
  75. C++ Working Draft: Library requirements
  76. Standard C++: The Standard
  77. CppCon 2023: C++23 Standard Library overview
  78. C++ Working Draft: Containers library
  79. CppCon 2023: New C++23 containers
  80. C++ Working Draft: Iterators library
  81. C++ Working Draft: Ranges library
  82. CppCon 2023: C++23 ranges changes
  83. C++ Working Draft: Algorithms library
  84. CppCon 2023: C++23 library formatting and print
  85. C++ Working Draft: Filesystem library
  86. C++ Working Draft: Time library
  87. WG21 Library Evolution Report: std::expected for C++23
  88. C++ Working Draft: Thread support library
  89. WG21 Papers 2022: std::generator proposal
  90. WG21 Library Evolution Report: mdspan targeting C++23
  91. C++ Working Draft: Concurrency support library
  92. Standard C++ FAQ: C++11 concurrency
  93. C++ Working Draft: Multi-threaded executions and data races
  94. C++ Working Draft: Data races
  95. cppreference: std::jthread
  96. C++ Working Draft: Atomic operations
  97. C++ Working Draft: Atomic order and consistency
  98. C++ Working Draft: Lock-free property
  99. C++ Core Guidelines: Concurrency
  100. C++ Working Draft: Algorithms parallel execution
  101. WG21 P2300R10: std::execution
  102. Current C++ Working Draft
  103. C++ Working Draft: Modules
  104. cppreference: Modules
  105. C++ Working Draft: Program and linkage
  106. C++ Working Draft: Module units and purviews
  107. C++ Working Draft: Export declaration
  108. C++ Working Draft: Module units and partitions
  109. C++ Working Draft: Global module fragment
  110. WG21 P1103R3: Merging Modules
  111. cppreference: Translation-unit-local entities
  112. C++ Working Draft: Standard library modules
  113. WG21 P1689R5: Format for describing dependencies of source files
  114. cppreference: C++20 compiler support
  115. ISO/IEC JTC1/SC22/WG14 — C
  116. ISO/IEC JTC1/SC22/WG21 — C++
  117. C++ Working Draft: C++ and C compatibility
  118. Bjarne Stroustrup, The C++ Programming Language, First Edition Preface
  119. Bjarne Stroustrup, Is C a subset of C++?
  120. C++ Working Draft: Annex C — Compatibility
  121. C++ Working Draft: C library headers
  122. C++ Working Draft: C and C++ compatible declarations
  123. C++ Working Draft: Types and C memory model compatibility
  124. WG14 N2704: C and C++ Compatibility Study Group Omnibus
  125. WG14 N2704: C and C++ Compatibility Study Group Omnibus
  126. Bjarne Stroustrup's FAQ
  127. Standard C++: The Standard
  128. Standard C++: Current Status
  129. WG21 Papers 2026
  130. ISO/IEC JTC1/SC22/WG21 — The C++ Standards Committee
  131. C++ Standard Core Language Active Issues
  132. C++ Standard Library Active Issues
  133. GCC C++ Standards Support
  134. Clang C++ Programming Language Status
  135. Microsoft C/C++ Language Conformance
  136. Microsoft C++ Conformance Improvements
  137. [Microsoft /std Language Standard Option](https://learn.microsoft.com/en-us/cpp/build/reference/std-specify-language-standard-version?view=msvc-170)
  138. Standard C++: The Standard
  139. CMake 공식 사이트
  140. CMake Buildsystem Documentation
  141. CMake Tutorial: Before You Begin
  142. Meson Build System
  143. Meson User Manual
  144. Conan 2 Documentation
  145. Conan 공식 사이트
  146. vcpkg Overview
  147. CLion Quick Start Guide
  148. Clang AddressSanitizer Documentation
  149. Clang UndefinedBehaviorSanitizer Documentation
  150. Clang ThreadSanitizer Documentation
  151. Catch2 공식 저장소
  152. Standard C++: Bjarne Stroustrup receives Draper Prize
  153. Standard C++: A Tour of C++
  154. ChromiumOS Development Basics: C++
  155. LLVM Project
  156. LLVM: Getting Started
  157. Unreal Engine: Programming with C++
  158. Epic C++ Coding Standard for Unreal Engine
  159. Chromium Source
  160. Chromium: For Developers
  161. Arduino: Getting Started
  162. Arduino Cloud: Arduino / C++
  163. ROS 2 rclcpp
  164. ROS 2: rclcpp::Node
  165. PostgreSQL: C-Language Functions
  166. ROOT Primer
  167. ROOT: About
  168. TensorFlow C++ API
  169. TensorFlow API Documentation
  170. Google AI Edge: On-device Inference with LiteRT
  171. Google AI Edge: Build LiteRT for ARM boards
  172. C++ Working Draft: C++ and ISO C
  173. Apple Developer: Objective-C Runtime