Skip to main content

Concepts 맛보기

Views 5약 8분생성형 AI 활용
Summary

템플릿은 너무 너그러워서 문제가 생긴다

앞 장에서 우리는 auto, decltype, 템플릿 매개변수 추론을 이용해 타입에 덜 의존하는 코드를 작성하는 법을 배웠습니다. 제네릭 코드는 매력적입니다. int에도 동작하고, double에도 동작하고, 직접 만든 클래스에도 동작할 수 있기 때문입니다.

하지만 템플릿에는 오래된 고민이 하나 있습니다. 너무 많은 타입을 받아들인다는 점입니다.

다음 함수를 봅시다.

#include <iostream>

class User
{
public:
    User(std::string name) : name_(std::move(name)) {}

private:
    std::string name_;
};

template <typename T>
T add(T a, T b)
{
    return a + b;
}

int main()
{
    std::cout << add(10, 20) << 'n';

    User a{"kim"};
    User b{"lee"};

    auto result = add(a, b); // 문제 발생
}

int끼리는 + 연산이 가능합니다. 그래서 add(10, 20)은 자연스럽게 동작합니다. 그런데 User 클래스에는 + 연산이 정의되어 있지 않습니다. 따라서 add(a, b)는 컴파일할 수 없습니다.

문제는 여기서 끝나지 않습니다. 예전 방식의 템플릿에서는 컴파일러가 일단 T에 User를 넣어 함수를 만들어 보다가, 함수 본문 안의 a + b에서 실패합니다. 실제 코드가 조금만 복잡해져도 에러 메시지는 함수 본문, 표준 라이브러리 내부, 템플릿 인스턴스화 과정으로 길게 이어집니다.

독자가 보고 싶은 메시지는 사실 아주 단순합니다.

"이 함수는 더할 수 있는 타입만 받을 수 있습니다."

그런데 컴파일러는 종종 훨씬 긴 이야기를 들려줍니다. "어떤 템플릿을 만들다가, 어떤 연산자를 찾지 못했고, 어떤 후보 함수는 조건이 맞지 않았고…" 같은 식입니다. 틀린 말은 아니지만, 입문자에게는 숲이 아니라 나뭇잎의 잎맥부터 보여 주는 셈입니다.

Concepts는 이 문제를 해결하기 위해 등장했습니다. 템플릿이 요구하는 타입의 능력을 함수 선언부에 직접 적을 수 있게 해 줍니다.

Concepts 맛보기

Concepts는 타입을 위한 조건문이다

Concept는 간단히 말해 "어떤 타입이 만족해야 하는 조건에 붙인 이름"입니다. 실행 중에 검사하는 조건문이 아니라, 컴파일할 때 검사하는 조건입니다.

예를 들어 다음 함수는 정수 타입만 받습니다.

#include <concepts>
#include <iostream>

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

int main()
{
    std::cout << doubleValue(10) << 'n';
    // std::cout << doubleValue(3.14) << 'n'; // 컴파일 오류
}

std::integral은 C++20 표준 라이브러리가 제공하는 concept입니다. int, long, char, bool처럼 정수 계열 타입이면 이 조건을 만족합니다. 반면 double은 정수 타입이 아니므로 조건을 만족하지 않습니다.

여기서 중요한 점은 함수 본문을 읽기 전에 이미 의도를 알 수 있다는 것입니다.

template <std::integral T>
T doubleValue(T value)

이 선언은 이렇게 읽을 수 있습니다.

"T는 정수 타입이어야 하며, 그런 T 값을 받아 두 배로 만든다."

템플릿에 문패가 생긴 셈입니다. 아무나 들어올 수 있는 문이 아니라, "정수 타입 출입 가능"이라고 적힌 문입니다.

concept를 쓰는 여러 가지 표기법

Concepts는 한 가지 방식으로만 쓰지 않습니다. 상황에 따라 조금 다른 형태로 적을 수 있습니다. 먼저 가장 직관적인 형태는 템플릿 매개변수 자리에 concept를 쓰는 방식입니다.

#include <concepts>

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

이 방식은 짧고 읽기 쉽습니다. typename T라고 쓰던 자리에 std::integral T라고 쓰면 됩니다. "아무 타입 T"가 아니라 "정수 타입 T"가 됩니다.

같은 코드를 requires 절로도 표현할 수 있습니다.

#include <concepts>

template <typename T>
requires std::integral<T>
T square(T value)
{
    return value * value;
}

requires std::integral<T>는 "이 템플릿은 T가 std::integral 조건을 만족할 때만 사용할 수 있다"는 뜻입니다. 조건이 길어지거나 여러 조건을 조합할 때는 이 방식이 더 읽기 좋습니다.

더 짧은 형태도 있습니다.

#include <concepts>

std::integral auto square(std::integral auto value)
{
    return value * value;
}

이런 형태를 축약 함수 템플릿이라고 부릅니다. 겉보기에는 일반 함수처럼 보이지만 실제로는 템플릿입니다. 다만 입문 단계에서는 첫 번째 방식과 두 번째 방식을 더 권합니다. 타입 이름 T가 보이기 때문에 함수 전체의 타입 관계를 이해하기 쉽습니다.

특히 매개변수 두 개가 같은 타입이어야 한다면 다음처럼 명시하는 편이 좋습니다.

#include <concepts>

template <std::integral T>
T add(T a, T b)
{
    return a + b;
}

반면 다음 코드는 두 매개변수가 각각 정수 타입이면 됩니다.

#include <concepts>

auto add(std::integral auto a, std::integral auto b)
{
    return a + b;
}

add(10, 20L)처럼 int와 long을 섞어도 호출될 수 있습니다. 어느 쪽이 더 좋은지는 상황에 따라 다릅니다. 중요한 것은 concept가 단순히 문법을 줄이는 도구가 아니라, 함수가 원하는 타입 관계를 표현하는 도구라는 점입니다.

표준 concept로 의도를 드러내기

C++20의 <concepts> 헤더에는 자주 쓰는 조건이 미리 준비되어 있습니다. 이것들을 잘 활용하면 직접 복잡한 조건을 만들지 않아도 함수의 의도를 꽤 선명하게 표현할 수 있습니다.

대표적인 표준 concept는 다음과 같습니다.

concept의미
std::integral<T>T가 정수 계열 타입인지 검사합니다.
std::floating_point<T>T가 부동소수점 타입인지 검사합니다.
std::same_as<T, U>T와 U가 같은 타입인지 검사합니다.
std::convertible_to<T, U>T가 U로 변환될 수 있는지 검사합니다.
std::derived_from<T, U>T가 U를 상속한 타입인지 검사합니다.
std::totally_ordered<T>T 값끼리 순서를 비교할 수 있는지 검사합니다.

예를 들어 정수와 실수를 나누어 처리하고 싶다면 다음처럼 쓸 수 있습니다.

#include <concepts>
#include <iostream>

template <std::integral T>
void printNumberKind(T value)
{
    std::cout << value << " is an integer-like valuen";
}

template <std::floating_point T>
void printNumberKind(T value)
{
    std::cout << value << " is a floating-point valuen";
}

int main()
{
    printNumberKind(42);
    printNumberKind(3.14);
}

여기서는 함수 오버로딩과 concept가 함께 사용되었습니다. 이름은 같지만 하나는 정수 타입을 받고, 다른 하나는 부동소수점 타입을 받습니다. 예전에도 오버로딩은 가능했지만, concept를 쓰면 "어떤 종류의 타입을 받는 함수인지"가 훨씬 잘 보입니다.

정렬 가능한 값을 다루고 싶을 때는 std::totally_ordered를 사용할 수 있습니다.

#include <algorithm>
#include <concepts>
#include <vector>

template <std::totally_ordered T>
void sortValues(std::vector<T>& values)
{
    std::sort(values.begin(), values.end());
}

이 함수는 T 값끼리 순서를 비교할 수 있어야 한다는 의도를 드러냅니다. int, double, std::string처럼 기본적인 비교가 가능한 타입은 자연스럽게 사용할 수 있습니다. 반면 비교 연산이 정의되지 않은 직접 만든 클래스는 사용할 수 없습니다.

물론 실제 정렬 알고리즘은 비교 가능성뿐 아니라 원소를 이동하거나 교환할 수 있는지도 필요합니다. 표준 라이브러리 내부에는 그런 조건들이 더 세밀하게 반영되어 있습니다. 하지만 입문 단계에서는 "정렬하려면 값 사이의 순서가 있어야 한다"는 요구사항을 함수 선언부에 드러내는 감각이 중요합니다.

requires는 타입이 할 수 있는 일을 검사한다

표준 concept만으로 모든 의도를 표현할 수는 없습니다. 어떤 함수는 "이 타입은 size() 멤버 함수를 가져야 한다"거나 "이 타입은 std::cout << value로 출력할 수 있어야 한다" 같은 조건을 요구할 수 있습니다.

이때 사용하는 것이 requires 표현식입니다.

다음은 size()를 호출할 수 있는 타입만 받는 concept입니다.

#include <concepts>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>

template <typename T>
concept HasSize = requires(const T& value)
{
    { value.size() } -> std::convertible_to<std::size_t>;
};

template <HasSize T>
void printSize(const T& value)
{
    std::cout << "size: " << value.size() << 'n';
}

int main()
{
    std::string name = "C++";
    std::vector<int> numbers{1, 2, 3};

    printSize(name);
    printSize(numbers);

    // printSize(10); // int에는 size()가 없으므로 컴파일 오류
}

이 코드를 천천히 읽어 봅시다.

template <typename T>
concept HasSize = requires(const T& value)
{
    { value.size() } -> std::convertible_to<std::size_t>;
};

HasSize는 concept의 이름입니다. requires(const T& value) 안의 value는 실제 객체를 만드는 코드가 아닙니다. "만약 T 타입의 const 참조 값이 있다면" 하고 가정해 보는 검사 도구입니다.

그다음 줄은 조건입니다.

{ value.size() } -> std::convertible_to<std::size_t>;

이 문장은 이렇게 읽을 수 있습니다.

"value.size()라는 표현식이 가능해야 하고, 그 결과는 std::size_t로 변환될 수 있어야 한다."

std::string과 std::vector<int>는 size()를 가지고 있으므로 조건을 만족합니다. int는 size()가 없으므로 조건을 만족하지 못합니다.

Concepts가 없었다면 printSize(10)을 호출했을 때 함수 본문 안의 value.size()에서 에러가 났을 것입니다. Concepts를 쓰면 함수 선언부에서 이미 "이 함수는 크기를 가진 타입만 받는다"고 말할 수 있습니다.

출력 가능한 타입이라는 조건 만들기

이번에는 std::cout << value로 출력할 수 있는 타입만 받는 concept를 만들어 봅시다.

#include <iostream>
#include <string>

template <typename T>
concept Printable = requires(std::ostream& os, const T& value)
{
    os << value;
};

template <Printable T>
void printLine(const T& value)
{
    std::cout << value << 'n';
}

class User
{
public:
    User(std::string name) : name_(std::move(name)) {}

private:
    std::string name_;
};

int main()
{
    printLine(10);
    printLine(3.14);
    printLine(std::string{"hello"});

    User user{"kim"};
    // printLine(user); // 출력 연산자가 없으므로 컴파일 오류
}

Printable concept의 핵심은 이 부분입니다.

os << value;

이 표현식이 가능하면 Printable 조건을 만족합니다. int, double, std::string은 표준 출력 스트림으로 출력할 수 있으므로 문제가 없습니다. 하지만 User 클래스는 아직 출력 방법을 모릅니다. std::cout << user가 무엇을 해야 하는지 정의되어 있지 않기 때문입니다.

User도 출력 가능하게 만들고 싶다면 출력 연산자를 정의할 수 있습니다.

#include <iostream>
#include <string>

template <typename T>
concept Printable = requires(std::ostream& os, const T& value)
{
    os << value;
};

class User
{
public:
    User(std::string name) : name_(std::move(name)) {}

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

private:
    std::string name_;
};

std::ostream& operator<<(std::ostream& os, const User& user)
{
    return os << "User(" << user.name() << ")";
}

template <Printable T>
void printLine(const T& value)
{
    std::cout << value << 'n';
}

int main()
{
    User user{"kim"};
    printLine(user);
}

이제 User는 Printable 조건을 만족합니다. 여기서 재미있는 점은 concept가 타입의 이름을 검사하지 않는다는 것입니다. "이 타입이 User인가?"를 보는 것이 아니라, "이 타입으로 os << value를 할 수 있는가?"를 봅니다.

이것이 제네릭 프로그래밍의 중요한 감각입니다. 타입의 정체보다 타입이 할 수 있는 일을 기준으로 코드를 설계합니다.

더할 수 있는 타입만 받기

처음의 add 예제로 돌아가 봅시다. 우리는 "더할 수 있는 타입만 받는 함수"를 만들고 싶었습니다. concept를 직접 만들면 다음처럼 표현할 수 있습니다.

#include <concepts>
#include <iostream>
#include <string>

template <typename T>
concept Addable = requires(T a, T b)
{
    { a + b } -> std::convertible_to<T>;
};

template <Addable T>
T add(T a, T b)
{
    return a + b;
}

int main()
{
    std::cout << add(10, 20) << 'n';
    std::cout << add(std::string{"Hello, "}, std::string{"C++"}) << 'n';
}

Addable은 다음 조건을 요구합니다.

{ a + b } -> std::convertible_to<T>;

즉 a + b라는 표현식이 가능해야 하고, 그 결과가 다시 T로 변환될 수 있어야 합니다.

int + int의 결과는 int입니다. std::string + std::string의 결과도 std::string입니다. 따라서 둘 다 Addable 조건을 만족합니다.

반대로 + 연산이 없는 타입은 이 함수를 사용할 수 없습니다. 이때 에러 메시지는 함수 본문 깊숙한 곳에서 갑자기 터지는 것이 아니라, Addable 조건을 만족하지 못했다는 방향으로 나타납니다. 컴파일러마다 표현은 조금씩 다르지만, 적어도 문제의 중심이 "타입 조건"이라는 점이 드러납니다.

여러 조건을 조합하기

실제 함수는 하나의 조건만 요구하지 않을 때가 많습니다. 예를 들어 값을 정렬한 뒤 출력하는 함수를 만들고 싶다면 두 가지 능력이 필요합니다. 값끼리 순서를 비교할 수 있어야 하고, 출력 스트림에 쓸 수 있어야 합니다.

이럴 때 concept를 조합할 수 있습니다.

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

template <typename T>
concept Printable = requires(std::ostream& os, const T& value)
{
    os << value;
};

template <typename T>
concept OrderedPrintable = std::totally_ordered<T> && Printable<T>;

template <OrderedPrintable T>
void sortAndPrint(std::vector<T> values)
{
    std::sort(values.begin(), values.end());

    for (const auto& value : values)
    {
        std::cout << value << ' ';
    }

    std::cout << 'n';
}

int main()
{
    sortAndPrint(std::vector<int>{3, 1, 2});
    sortAndPrint(std::vector<std::string>{"zerg", "terran", "protoss"});
}

OrderedPrintable은 두 조건을 합친 이름입니다.

template <typename T>
concept OrderedPrintable = std::totally_ordered<T> && Printable<T>;

이름을 붙이자 함수 선언이 훨씬 자연스러워졌습니다.

template <OrderedPrintable T>
void sortAndPrint(std::vector<T> values)

이 선언은 거의 문장처럼 읽힙니다.

"정렬 가능하고 출력 가능한 타입 T의 vector를 받아 정렬한 뒤 출력한다."

이것이 Concepts의 가장 큰 장점입니다. 복잡한 템플릿 기법을 숨기는 것이 아니라, 코드의 의도를 앞쪽에 드러냅니다.

requires 절과 requires 표현식은 다르다

Concepts를 처음 배울 때 헷갈리는 부분이 있습니다. requires라는 단어가 두 가지 모습으로 등장하기 때문입니다.

먼저 함수나 템플릿에 조건을 붙이는 requires 절이 있습니다.

template <typename T>
requires std::integral<T>
T nextValue(T value)
{
    return value + 1;
}

여기서 requires std::integral<T>는 함수 템플릿 전체에 붙은 조건입니다. T가 정수 타입일 때만 이 함수를 사용할 수 있습니다.

반면 어떤 표현식이 가능한지 검사하는 requires 표현식도 있습니다.

template <typename T>
concept HasEmpty = requires(const T& value)
{
    value.empty();
};

여기서 requires(const T& value)는 concept를 정의할 때 사용됩니다. "value.empty()라는 표현식이 가능한가?"를 검사합니다.

둘을 한꺼번에 쓰면 다음처럼 다소 낯선 코드가 나올 수 있습니다.

#include <iostream>

template <typename T>
requires requires(const T& value)
{
    value.empty();
}
void printEmptyState(const T& value)
{
    std::cout << std::boolalpha << value.empty() << 'n';
}

requires requires처럼 보여서 처음에는 이상하게 느껴집니다. 앞의 requires는 함수 템플릿에 조건을 붙이는 문법이고, 뒤의 requires는 검사할 표현식을 나열하는 문법입니다.

하지만 이런 코드는 읽기 불편합니다. 그래서 보통은 concept에 이름을 붙입니다.

#include <iostream>

template <typename T>
concept HasEmpty = requires(const T& value)
{
    value.empty();
};

template <HasEmpty T>
void printEmptyState(const T& value)
{
    std::cout << std::boolalpha << value.empty() << 'n';
}

이제 함수 선언이 훨씬 깔끔해졌습니다. 좋은 concept 이름은 좋은 함수 이름만큼 중요합니다.

concept는 인터페이스 문서이기도 하다

클래스를 배울 때 우리는 public 인터페이스가 중요하다고 했습니다. 객체의 내부를 숨기고, 외부에서 무엇을 할 수 있는지만 드러내는 것이 캡슐화의 핵심이었습니다.

템플릿에서도 비슷한 일이 벌어집니다. 템플릿 함수의 본문은 구현입니다. 반면 concept는 그 함수가 타입에게 요구하는 인터페이스입니다.

다음 두 함수를 비교해 봅시다.

template <typename T>
void save(const T& value)
{
    value.serialize();
}

이 함수는 본문을 읽어야만 T에 serialize()가 필요하다는 사실을 알 수 있습니다. 호출하는 쪽에서 잘못된 타입을 넘기면 함수 본문 안에서 에러가 납니다.

Concept를 사용하면 요구사항이 앞으로 나옵니다.

template <typename T>
concept Serializable = requires(const T& value)
{
    value.serialize();
};

template <Serializable T>
void save(const T& value)
{
    value.serialize();
}

이제 save 함수는 선언만 봐도 Serializable한 타입을 저장한다는 사실을 알 수 있습니다. 함수 본문은 여전히 중요하지만, 사용자가 먼저 보게 되는 것은 요구사항입니다.

좋은 제네릭 코드는 "어떻게 구현했는가"만큼 "무엇을 요구하는가"를 분명히 말해야 합니다.

직접 만드는 작은 실습: 점수표 출력기

간단한 점수표 출력기를 만들어 보겠습니다. 목표는 다음과 같습니다. 여러 개의 값을 정렬한 뒤 한 줄씩 출력합니다. 단, 이 함수는 아무 타입이나 받으면 안 됩니다. 정렬할 수 있어야 하고, 출력할 수 있어야 합니다.

먼저 concept를 정의합니다.

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

template <typename T>
concept Printable = requires(std::ostream& os, const T& value)
{
    os << value;
};

template <typename T>
concept OrderedPrintable = std::totally_ordered<T> && Printable<T>;

이제 함수를 작성합니다.

template <OrderedPrintable T>
void printSorted(std::vector<T> values)
{
    std::sort(values.begin(), values.end());

    for (const auto& value : values)
    {
        std::cout << value << 'n';
    }
}

values를 값으로 받은 이유는 원본을 바꾸지 않기 위해서입니다. 함수 안에서 복사본을 정렬하고 출력합니다. 원본을 직접 정렬하고 싶다면 std::vector<T>&로 받을 수 있습니다.

이제 직접 만든 타입을 하나 추가해 보겠습니다.

class Score
{
public:
    Score(std::string name, int point)
        : name_(std::move(name)), point_(point)
    {
    }

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

    int point() const
    {
        return point_;
    }

    auto operator<=>(const Score& other) const
    {
        return point_ <=> other.point_;
    }

    bool operator==(const Score& other) const
    {
        return point_ == other.point_;
    }

private:
    std::string name_;
    int point_;
};

std::ostream& operator<<(std::ostream& os, const Score& score)
{
    return os << score.name() << ": " << score.point();
}

Score는 점수를 기준으로 비교되도록 <=> 연산자를 정의했습니다. <=>는 C++20에서 추가된 비교 연산자이며, 흔히 우주선 연산자라고도 부릅니다. 이름은 재미있지만 역할은 진지합니다. 두 값을 비교해 작다, 같다, 크다를 한 번에 표현할 수 있게 해 줍니다.

또한 operator<<를 정의해 출력 가능하게 만들었습니다.

이제 전체를 사용할 수 있습니다.

int main()
{
    std::vector<Score> scores{
        Score{"Mina", 90},
        Score{"Jun", 75},
        Score{"Ara", 88}
    };

    printSorted(scores);
}

실행 결과는 점수 오름차순으로 출력됩니다.

Jun: 75
Ara: 88
Mina: 90

이 예제에서 printSorted는 Score라는 구체적인 타입을 전혀 모릅니다. 하지만 Score가 정렬 가능하고 출력 가능하다는 사실은 압니다. 바로 그 사실이 concept로 표현되어 있기 때문입니다.

concept가 만능은 아니다

Concepts는 템플릿 코드를 훨씬 읽기 쉽게 만들어 주지만, 모든 문제를 자동으로 해결해 주지는 않습니다. 잘못 만든 concept는 오히려 코드를 헷갈리게 만들 수 있습니다.

예를 들어 다음 concept는 이름이 너무 막연합니다.

template <typename T>
concept Good = requires(T value)
{
    value.run();
};

Good이라는 이름은 아무 의미도 설명하지 못합니다. 무엇이 좋은지 알 수 없습니다. 이런 이름보다는 Runnable, Serializable, Printable, HasSize처럼 타입이 제공해야 하는 능력을 드러내는 이름이 좋습니다.

또한 concept를 지나치게 자세히 만들 필요도 없습니다. 어떤 함수가 실제로는 value.size()만 필요로 하는데, begin(), end(), empty(), clear()까지 모두 요구한다면 쓸 수 있는 타입의 범위가 불필요하게 좁아집니다.

좋은 concept를 만들 때는 함수가 진짜로 필요로 하는 능력만 요구해야 합니다. 이것은 좋은 함수 매개변수를 설계하는 일과 비슷합니다. 필요 이상으로 많은 것을 요구하면 코드는 딱딱해지고, 너무 적게 요구하면 함수 본문에서 에러가 터집니다.

C++20 이후의 템플릿 스타일

C++20 이전에도 템플릿 타입에 조건을 걸 수는 있었습니다. std::enable_if나 SFINAE라는 기법을 사용했습니다. SFINAE는 "치환 실패는 오류가 아니다"라는 뜻의 규칙입니다. 강력하지만 문법이 어렵고, 입문자에게는 거의 암호처럼 보입니다.

Concepts는 이런 복잡한 기법을 더 읽기 쉬운 방향으로 바꾸었습니다.

예전 스타일의 제네릭 코드는 종종 구현에 초점이 있었습니다. 함수 본문에서 어떤 연산을 시도하고, 실패하면 긴 에러 메시지를 통해 문제를 알게 되는 식이었습니다. C++20 이후의 스타일은 점점 요구사항을 먼저 적는 방향으로 이동하고 있습니다.

즉 현대 C++의 템플릿은 다음 질문에 더 분명히 답하려고 합니다.

"이 함수는 어떤 타입을 받을 수 있는가?"

"그 타입은 어떤 연산을 지원해야 하는가?"

"함수를 호출하는 사람은 선언만 보고 요구사항을 이해할 수 있는가?"

Concepts는 이 질문들에 답하기 위한 언어 기능입니다.

언제 concept를 써야 할까?

모든 템플릿에 반드시 concept를 붙여야 하는 것은 아닙니다. 아주 작은 연습 코드나 함수 본문만 봐도 요구사항이 명확한 경우에는 단순한 template <typename T>도 괜찮습니다.

하지만 다음과 같은 상황에서는 concept를 적극적으로 고려하는 것이 좋습니다.

  • 함수가 특정 연산을 요구할 때

  • 에러 메시지가 너무 복잡해져 사용자가 원인을 찾기 어려울 때

  • 라이브러리처럼 여러 사람이 호출할 코드를 작성할 때

  • 타입 조건 자체가 함수의 중요한 의미일 때

예를 들어 printLine은 출력 가능해야 한다는 조건이 핵심입니다. sortAndPrint는 정렬 가능하고 출력 가능해야 한다는 조건이 핵심입니다. 이런 함수는 concept를 붙였을 때 코드의 의도가 훨씬 선명해집니다.

반대로 내부에서만 잠깐 쓰는 매우 단순한 템플릿 함수라면 처음부터 복잡한 concept를 만들 필요는 없습니다. C++다운 코드는 기능을 많이 쓰는 코드가 아니라, 필요한 기능을 적절한 위치에 쓰는 코드입니다.

이번 장에서 기억할 것

Concepts는 템플릿을 더 안전하고 읽기 쉽게 만드는 C++20의 핵심 기능입니다. 템플릿은 원래 아무 타입이나 받을 수 있기 때문에, 잘못된 타입을 넣었을 때 함수 본문이나 표준 라이브러리 내부에서 복잡한 에러가 발생하기 쉽습니다. Concepts는 타입이 만족해야 할 조건을 함수 선언부에 드러내어 이런 문제를 줄여 줍니다.

std::integral, std::floating_point, std::same_as, std::convertible_to, std::totally_ordered 같은 표준 concept를 사용하면 자주 쓰는 타입 조건을 간결하게 표현할 수 있습니다. 더 구체적인 요구사항이 필요할 때는 requires 표현식으로 직접 concept를 만들 수 있습니다.

핵심은 단순합니다. 제네릭 코드는 아무 타입이나 받는 코드가 아닙니다. 필요한 능력을 가진 타입을 받는 코드입니다. Concepts는 그 능력에 이름을 붙이고, 함수의 입구에 걸어 두는 표지판입니다.

다음 장에서는 프로그램이 원하는 대로만 흘러가지 않을 때, 즉 에러가 발생했을 때 어떻게 처리할지 배웁니다. C++의 예외 처리는 함수 호출 흐름을 바꾸는 강력한 도구이며, RAII와 함께 사용할 때 특히 중요한 의미를 갖습니다.

책 목록

처음 만나는 C++: 객체지향에서 현대 C++까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기