Devin.KR

C++ 템플릿 첫걸음 - 함수 템플릿, 클래스 템플릿 링 버퍼, concepts (C++ 객체와 자원 관리 10장)

개발자 조회 1

이 장에서 배우는 것

std::vector<int>std::vector<std::string>은 같은 코드에서 나온 서로 다른 타입이다. 이 "타입을 매개변수로 받는 코드"가 템플릿이다. C에서는 같은 일을 매크로나 void*로 했다. 매크로는 타입 검사가 없고, void*는 타입 정보를 잃는다. 템플릿은 컴파일러가 타입마다 코드를 따로 만들어 주므로 타입 검사도 되고 성능 손해도 없다.

  • 함수 템플릿 clamp_to를 만들고 타입 추론이 어떻게 일어나는지, 실패하면 오류가 어떻게 보이는지 본다.
  • 임베디드와 게임에서 자주 쓰는 고정 크기 링 버퍼를 클래스 템플릿 RingBuffer<T, N>으로 만든다. 크기 N도 템플릿 인자다.
  • C++20 concepts로 "정수 타입만 받는다" 같은 조건을 붙이고, if constexpr로 타입에 따라 다른 코드를 고른다.

이 장은 첫걸음이다. 가변 인자 템플릿, 특수화, 타입 특성 설계는 심화 책의 주제로 남긴다.

문제 상황

로봇 제어 코드에 값의 범위를 제한하는 함수가 필요하다. 모터 PWM 값은 int로 0~100, 조향각은 double로 -1.0~1.0이다. 함수를 clamp_int, clamp_double로 두 벌 만들면 버그 수정도 두 번 해야 한다. 최근 센서 값 몇 개를 기억하는 링 버퍼도 필요한데, 어떤 곳은 int 3개, 어떤 곳은 float 64개다. 임베디드 환경에서는 힙 할당을 피하고 싶어서 std::vector 대신 크기가 컴파일 때 정해지는 배열을 쓰고 싶다.

완성 코드

templates.cpp로 저장한다.

#include <array>
#include <concepts>
#include <cstddef>
#include <iostream>
#include <optional>
#include <string>
#include <type_traits>

template <typename T>
T clamp_to(T value, T low, T high) {
    if (value < low) return low;
    if (high < value) return high;
    return value;
}

template <typename T, std::size_t N>
class RingBuffer {
public:
    void push(const T& value) {
        data_[head_] = value;
        head_ = (head_ + 1) % N;
        if (count_ < N) ++count_;
    }

    std::optional<T> oldest() const {
        if (count_ == 0) return std::nullopt;
        return data_[(head_ + N - count_) % N];
    }

    std::size_t size() const { return count_; }
    static constexpr std::size_t capacity() { return N; }

private:
    std::array<T, N> data_{};
    std::size_t head_ = 0;
    std::size_t count_ = 0;
};

template <std::integral T>
T average(const T* values, std::size_t n) {
    T sum = 0;
    for (std::size_t i = 0; i < n; ++i) sum += values[i];
    return n == 0 ? 0 : sum / static_cast<T>(n);
}

template <typename T>
std::string describe(const T& value) {
    if constexpr (std::is_arithmetic_v<T>) return "숫자 " + std::to_string(value);
    else return "문자열 " + std::string(value);
}

int main() {
    std::cout << "clamp_to<int>: " << clamp_to(130, 0, 100) << '\n';
    std::cout << "clamp_to<double>: " << clamp_to(-0.5, 0.0, 1.0) << '\n';
    std::cout << "clamp_to<std::string>: " << clamp_to<std::string>("zeta", "alpha", "omega") << '\n';

    RingBuffer<int, 3> recent;
    for (int v : {10, 20, 30, 40}) recent.push(v);
    std::cout << "RingBuffer<int, 3>: size " << recent.size() << "/" << recent.capacity()
              << ", oldest " << *recent.oldest() << '\n';

    int ticks[] = {3, 4, 8};
    std::cout << "average<int>: " << average(ticks, 3) << '\n';

    std::cout << describe(42) << ", " << describe("gps") << '\n';
}

clamp_to 템플릿은 호출에서 추론한 T 마다 별도의 함수를 만든다. 인자 타입이 서로 다르면 T 를 하나로 정할 수 없어 아무 함수도 만들지 못하고 컴파일 오류가 난다.

줄별 해설

template <typename T> T clamp_to(T value, T low, T high)

T는 아직 정해지지 않은 타입의 이름이다. 이 코드는 그 자체로는 함수가 아니라 함수를 만드는 틀이다. clamp_to(130, 0, 100)을 부르면 컴파일러가 인자를 보고 T = int라고 추론int용 함수를 만든다. double로 부르면 double용이 또 만들어진다. 이 과정을 인스턴스화라고 한다. 본문에서 <만 썼으므로 < 비교가 되는 타입이면 무엇이든 된다.

clamp_to<std::string>("zeta", "alpha", "omega")

인자가 문자열 리터럴이라 그대로 추론하면 Tconst char*가 되어 주소를 비교해 버린다. 꺾쇠로 T를 직접 지정하면 리터럴이 std::string으로 변환되어 문자열 비교가 된다. "zeta"는 "omega"보다 사전순으로 뒤라서 "omega"가 나온다.

template <typename T, std::size_t N> class RingBuffer

템플릿 인자는 타입만이 아니다. N처럼 컴파일 때 정해지는 정수도 된다. 멤버 std::array<T, N>은 크기가 타입에 박힌 고정 배열이라 힙을 쓰지 않는다. RingBuffer<int, 3>을 지역 변수로 만들면 전부 스택에 들어간다. 가장 오래된 원소의 위치는 (head_ + N - count_) % N으로 계산한다. N이 상수이므로 컴파일러가 나머지 연산을 최적화할 수 있다.

std::optional<T> oldest() const

버퍼가 비었을 때 돌려줄 "없음"을 표현한다(9장). 아무 값이나 돌려주거나 예외를 던지는 것보다 호출한 쪽이 확인하기 쉽다.

static constexpr std::size_t capacity()

객체 없이도 부를 수 있고(static), 컴파일 때 값이 정해진다(constexpr). static_assert(RingBuffer<int, 3>::capacity() == 3);처럼 컴파일 시점 검사에도 쓸 수 있다.

template <std::integral T> — concepts

typename 자리에 개념(concept)을 쓰면 "T는 정수 타입이어야 한다"는 조건이 붙는다. 정수 평균은 나눗셈이 정수 나눗셈이라는 것을 알고 쓰는 함수이므로, 실수 배열을 넘기는 실수를 막고 싶다. std::integral, std::floating_point, std::totally_ordered 같은 표준 개념이 <concepts>에 있다.

if constexpr

조건이 컴파일 때 계산되고, 거짓인 쪽 코드는 그 타입에 대해 아예 만들어지지 않는다. describe(42)에서는 std::string(value)가, describe("gps")에서는 std::to_string(value)가 컴파일되지 않는다. 일반 if였다면 양쪽 모두 컴파일되어야 해서 오류가 난다.

실제 실행 결과

$ clang++ -std=c++20 -Wall -Wextra templates.cpp -o templates && ./templates
clamp_to<int>: 100
clamp_to<double>: 0
clamp_to<std::string>: omega
RingBuffer<int, 3>: size 3/3, oldest 20
average<int>: 5
숫자 42, 문자열 gps

용량 3 인 링 버퍼에 네 번째 값 40 을 넣으면 가장 오래된 10 을 덮는다. 다음에 쓸 위치 head_ 와 가장 오래된 위치가 모두 1번 칸이고 oldest() 는 20 이다.

링 버퍼에 10, 20, 30, 40을 넣었더니 용량이 3이라 10이 밀려났고 가장 오래된 값은 20이다. 정수 평균은 (3 + 4 + 8) / 3 = 15 / 3 = 5다.

표 10-1. 이 장에서 쓴 템플릿 인자의 종류

종류정해지는 때
타입typename T호출 인자에서 추론 또는 명시
정수 값std::size_t N꺾쇠 안에 상수로 명시
개념으로 제한한 타입std::integral T추론 후 조건 검사

템플릿 오류 읽기

템플릿 오류는 길기로 유명하다. 요령은 첫 번째 error 줄과 그 바로 아래 note만 먼저 읽는 것이다.

#include <concepts>
#include <cstddef>

template <typename T>
T clamp_to(T value, T low, T high) {
    if (value < low) return low;
    if (high < value) return high;
    return value;
}

template <std::integral T>
T average(const T* values, std::size_t n) {
    T sum = 0;
    for (std::size_t i = 0; i < n; ++i) sum += values[i];
    return sum / static_cast<T>(n);
}

int main() {
    clamp_to(5, 0.0, 10.0);
    double speeds[] = {1.5, 2.5};
    average(speeds, 2);
}
$ clang++ -std=c++20 -Wall -Wextra -c template_errors.cpp
template_errors.cpp:19:5: error: no matching function for call to 'clamp_to'
   19 |     clamp_to(5, 0.0, 10.0);
      |     ^~~~~~~~
template_errors.cpp:5:3: note: candidate template ignored: deduced conflicting types for parameter 'T' ('int' vs. 'double')
    5 | T clamp_to(T value, T low, T high) {
      |   ^
template_errors.cpp:21:5: error: no matching function for call to 'average'
   21 |     average(speeds, 2);
      |     ^~~~~~~
template_errors.cpp:12:3: note: candidate template ignored: constraints not satisfied [with T = double]
   12 | T average(const T* values, std::size_t n) {
      |   ^
template_errors.cpp:11:11: note: because 'double' does not satisfy 'integral'
   11 | template <std::integral T>
      |           ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/v1/__concepts/arithmetic.h:30:20: note: because 'is_integral_v<double>' evaluated to false
   30 | concept integral = is_integral_v<_Tp>;
      |                    ^
2 errors generated.
  • 첫 오류: clamp_to(5, 0.0, 10.0)에서 첫 인자는 int, 나머지는 double이라 T를 하나로 정할 수 없다. note가 "deduced conflicting types for parameter 'T' ('int' vs. 'double')"라고 정확히 말한다. 템플릿 추론은 암묵적 변환을 해 주지 않는다. clamp_to<double>(5, 0.0, 10.0)처럼 타입을 지정하거나 5.0을 쓴다.
  • 두 번째 오류: averagedouble 배열을 넘겼다. concepts 덕분에 "because 'double' does not satisfy 'integral'"이라는 사람이 읽을 수 있는 이유가 나왔다. 개념이 없었다면 함수 본문 깊숙한 곳에서 생긴 오류가 여러 겹의 인스턴스화 경로와 함께 출력되었을 것이다.

템플릿은 헤더에 둔다

템플릿은 부르는 곳에서 타입이 정해져야 코드가 만들어진다. 그래서 컴파일러가 호출하는 소스 파일을 컴파일할 때 템플릿의 본문 전체를 볼 수 있어야 한다. 일반 함수처럼 선언은 헤더에, 본문은 .cpp에 나누면 링크 오류가 난다. 템플릿은 본문까지 헤더에 쓰는 것이 기본이다. 헤더와 소스, 링크의 관계는 다음 장에서 자세히 본다.

표 10-2. 자주 쓰는 표준 개념(<concepts>)

개념만족하는 타입
std::integralint, long, char, bool 등 정수
std::floating_pointfloat, double
std::totally_ordered<, == 등 비교가 모두 되는 타입
std::copyable / std::movable복사 / 이동이 되는 타입

실무에서 자주 틀리는 것

1. 문자열 리터럴로 템플릿을 부른다

위에서 본 대로 Tconst char*가 되어 내용이 아닌 주소를 비교한다. 컴파일도 되고 결과도 "그럴듯하게" 나와서 찾기 어렵다. 타입을 명시하거나 std::string/std::string_view를 넘긴다.

2. 모든 것을 템플릿으로 만든다

템플릿은 타입마다 코드를 새로 만든다. 여러 타입으로 쓰면 바이너리가 커지고(임베디드에서는 플래시 크기가 한정되어 있다), 컴파일도 느려진다. 실제로 두 가지 이상의 타입에 쓰일 때만 템플릿으로 만든다.

3. 템플릿 본문을 .cpp로 옮긴다

헤더를 가볍게 하려고 옮겼다가 undefined symbol 링크 오류를 만난다. 템플릿 본문은 헤더에 둔다.

4. 조건을 주석으로만 적는다

"T는 정수여야 함"이라고 주석으로만 쓰면 아무도 검사하지 않는다. C++20이라면 개념으로 적는다. 오류 메시지가 짧아지는 것은 덤이다.

연습 문제

  1. clamp_to(5, 0.0, 10.0)이 컴파일되지 않는다. 함수 정의를 바꾸지 않고 호출만 고치는 방법 두 가지를 써라.
  2. RingBuffer<int, 3>RingBuffer<int, 4>는 같은 타입인가? RingBuffer<int, 3>을 받는 함수에 RingBuffer<int, 4>를 넘길 수 있는가?
  3. 스트림에 <<로 출력할 수 있는 타입만 받는 개념 Printablerequires 식으로 써라. int와 빈 구조체 struct NoPrint {};는 각각 이 개념을 만족하는가?

정답

  1. (가) 타입을 명시한다: clamp_to<double>(5, 0.0, 10.0). 추론을 하지 않으므로 5가 double로 변환된다. (나) 인자 타입을 맞춘다: clamp_to(5.0, 0.0, 10.0). 검증 프로그램에서 (가)의 결과가 5.0인 것을 확인했다.
  2. 다른 타입이다. 템플릿 인자가 하나라도 다르면 완전히 별개의 클래스가 만들어진다. 따라서 넘길 수 없다(컴파일 오류). 크기와 상관없이 받고 싶다면 그 함수도 template <std::size_t N> void f(const RingBuffer<int, N>&)처럼 템플릿으로 만든다. 검증 프로그램에서 std::is_same_vfalse인 것을 확인했다.
  3. template <typename T> concept Printable = requires(std::ostream& os, T v) { os << v; }; 중괄호 안의 식이 컴파일될 수 있으면 만족이다. int는 만족하고 NoPrint<<가 없어 만족하지 않는다. 검증 프로그램에서 두 결과를 확인했다.

다음 장에서는 한 파일에 모든 코드를 넣던 방식에서 벗어나, 헤더와 소스 파일로 나누고 링크하는 과정을 직접 실행해 본다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.