본문으로 바로가기

C++20) std::format

category C++/Modern 2022. 1. 11. 22:18

C++ 은 언어에 텍스트 형식을 가져오기 위해 예전부터 여러 시도를 했었습니다.

첫 번째로는, C에서 상속된 printf 입니다.

std::printf("hello %s\n", "world!");

std::printf 는 간결하고, 빠르며, 완벽하게 작동합니다.

요즘은 디버거들과 IDE 들이 printf()같은 API를 통합하고 있을 정도로 보편적이고 성공적이였습니다.

현재 2022년에도 아직도 printf로 디버깅 (변수 값을 찍어본다거나..) 하는 분들 계시잖아요?

 

그러나 너무 간결한게 문제였습니다.

std::printfbuilt-in type 들 (int, const char* 등..) 에 대해서만 작동했고 포매팅 (%d, %c) 은 안전하지 않았으며 varargs 같은 가변 인자를 사용했었습니다.

 

몇 년 후 사람들은 타입 안정성과 사용자 정의 타입의 통합을 염두에 두고, 새로운 Input-Output API에 대한 작업을 시작했습니다. 이 작업이 현재 우리가 C++에서 자주 사용하는 표준 <iostream> 입니다.

std::string world{"world"};
std::cout << "hello" << world << std::endl;

확실히 타입 안정성과 확장성 측면에서는 개선되었습니다.

그러나  성능저하 잘못된 설계 너무 많은 'operator<<' 때문에 말이 많았습니다.

std::cout << "oh yeah, "
          << "this is "
          << definitely()
          << " not verbose "
          << *at_all 
          << std::endl;

C++20 의 <format>은 새로운 텍스트 서식 API 입니다.

stream 형식의 문제점을 고치면서도 printf의 단순함을 챙겼습니다.

 

<format> 은 세 가지 간단한 원칙을 기반으로 하는 텍스트 서식 지정 라이브러리입니다.

  • 색인화된 인수 및 타입 지정에 대한 지원을 포함하는 자리 표시자(placeholder) 기반 형식 구문
  • 가변 인자 지원을 위한 가변 템플릿을 사용하는 타입 안전 형식
  • 사용자 정의 포매터(User-defined formatter)를 통한 사용자 정의 타입 지원

주요 기능인 std::format은 주어진 인수의 타입을 지정하고 문자열을 반환합니다.

#include <format>

int main(){
	const std::string message {std::format("{}, {}!", "hello", "world")};
}

자리 표시자(place holder)는 인덱싱되어 인자의 순서를 변경하거나 반복할 수도 있습니다.

다음의 호출은 모두 "hello, world!"를 반환합니다.

std::format("{1}, {0}!", "world", "hello");
std::format("he{0}{0}o, {1}!", "l", "world");

 

std::format 함수는 다음과 같이 정의되어 있습니다.

std::string_viewstd::wstring_view를 사용합니다.

 

fmr - 인자가 std::string_view 또는 std::wstring_view로 변환될 수 있는 경우에만 가능합니다.

타입 문자열은 다음과 같이 구성됩니다.

  • 일반적인 문자들 ( '{' 와 '}' 는 제외 )은 복사됩니다.
  • return되는 문자열에선 '{{' '}}' 같은 이스케이프 시퀀스가 '{' '}'로 대체됩니다.
  • replacement fields
    ("{}, {}!", "hello", "world")​
    에서 "{}"replacement fields 라고 합니다. 형식은 다음과 같습니다.

    1. { arg-id(optional) }
    2. { arg-id(optional) : format_spec } 

    arg-id : 인덱스를 지정하여 args 값을 사용하게 됩니다. 생략하면 인자가 순서대로 사용됩니다.
               만약 생략한 필드와 인덱싱을 추가한 필드가 둘 다 존재하다면 오류입니다.

    format_spec : 해당 인자에 대한 std::formatter specification 의해 정의된 사양입니다.
    (1). built-in type 과 표준 문자열 타입들은 표준 사양으로 해석됩니다.
    (2). std::chrono 유형의 경우 크로노 사양으로 해석됩니다.
    (3). 그 외엔 사용자 정의 formatter specification에 의해 결정됩니다.

std::format에 replacement fields 보다 더 많은 args를 제공하는 것은 오류가 아닙니다.

 

std::format() 이외에도 std::format_to()를 사용하여 결과 문자열을 std::string에 직접 할당하는 대신 iterator를 가져다 쓸 수 있습니다.

// file input output
std::ofstream file{"format.txt"};
std::format_to(std::ostream_iterator<char>{file}, "hello, {}!", "world");

//container
std::Vector<char> buffer{};
std::format_to(std::ostream_iterator<char>{buffer}, "hello, world");

 

지금까지의 예제에서는 문자열 인자만 포함되었지만 std::format은 사용할 수 있는 모든 종류의 형식을 지원합니다.

(위에서 설명한 format_spec 참조)

built-in type이나 chrono, 문자열은 사전 정의된 포매터가 있습니다.

#include <chrono>
#include <format>

const std::string str{ std::format("Just {} days left for the release, right?", std::chrono::days(42)) };
const std::string number{ std::format("Number is {}", 5) };

 

포매터는 값의 문자열 표현을 반환할 뿐만 아니라 Formatting specifiers 를 통해 출력을 정의할 수 있습니다.

1개 format("{}", 1) 1
2개 format("{} {}", 1, 2) 1 2
3개 format("{} {} {}", 1, 2, 3) 1 2 3
순서 지정 format("{2} {1} {0}", 1, 2, 3) 3 2 1
문자(c는 생략 가능) format("{:c}", 'C') C
문자열(s는 생략 가능) format("{:s}", "C++") C++
bool format("{}", true) true
bool format("{}", false) false
정수(10진수,d생략 가능) format("{:d}", 42) 42
정수(8진수) format("{:o}", 042) 42
정수(16진수) format("{:x}", 0xab) ab
정수(16진수) 대문자 format("{:X}", 0xab) AB
정수(16진수) 0x 붙음 format("{:#x}", 0xab) 0xab
정수(16진수) 0X 붙음 format("{:#X}", 0xab) 0XAB
정수(2진수) format("{:b}", 0b10101010) 10101010
정수(2진수) 0b 붙음 format("{:#b}", 0b10101010) 0b10101010
정수(2진수) 0B 붙음 format("{:#B}", 0b10101010) 0B10101010
정수(정수에+) format("{:+d}", 42) +42
부동소수점수 format("{:f}", 123.456789) 123.456789
정도 지정 format("{:6.3f}", 123.456789) 123.457
지수 표기(e) format("{:e}", 123.456789) 1.234568e+02
지수 표기(E) format("{:E}", 123.456789) 1.234568E+02
최적의 표기를 자동 판정 (g는 생략 가능) format("{:g}", 123.456789) 123.457
왼쪽 정렬 format("{:<12}", "left") left
오른쪽 정렬 format("{:>12}", "right") right
중앙 정렬 format("{:^12}", "center") center
묻힐 문자 지정 format("{:!^12}", "hello") !!!hello!!!!
// 문자열을 3자리 수까지만 저장합니다.
const std::string pi = std::format("{:.3f}", 3.141592654);

 

만약 우리가 직접 정의한 타입이 (User-defined type) 포매팅에 사용되려면 두 가지 방법이 있습니다.

  • (펑소처럼) std::ostream에 operator<< 재정의 해서 사용하기
  • UDT에 대한 사용자 정의 포매터 (User-defined formatter) 작성

(펑소처럼) std::ostream에 operator<< 재정의 해서 사용하기

아주 간단합니다. 

<format>은 출력 스트림과 호환되는 모든 유형이 포맷 라이브러리와 호환되도록 Stream 라이브러리와 상호 운용됩니다.

#include <ostream>
#include <format>

enum class State{
    On,
    Off
};

std::ostream& operator<<(std::ostream& os, const State state){
    switch(state){
    case State::On:
        return os << "On";
    case State::Off:
        return os << "Off";
    }

    return os;
}

const std::string current_mode = std::format("current mode is {}", Mode::On);

이렇게 하면 format에 stream 오버헤드가 추가된다는 단점이 있지만 기존 유형들이 operator<<를 지원했다면 <format>으로 마이그레이션 하는 것이 엄청 쉬워집니다.

 

UDT에 대한 사용자 정의 포매터 (User-defined formatter) 작성

 

사용자 정의 포매터를 작성하려면 유형에 맞는 std::formatter 를 특수화 해야 합니다.

template<>
struct std::formatter<State>{
	std::format_parse_context::iterator parse(std::format_parse_context& context){
    	// ...
    }
    
    std::format_parse_contexT::iterator format(
    	const State state,
        std::format_context& context){
        // ...
    }
};

 

특수화에는 두 개의 멤버 함수가 포함되어야 합니다.

  • parse(context) : replacement field (placeholder)가 있는 경우 타입을 구문 분석하는 역할을 합니다.
    즉, 타입 문자열의 "{}" 내부에 있는 내용을 구문 분석하는 함수입니다. 만약 replacement field가 발견되면 std::formatter 객체에 저장해야 합니다. ( this 함수 context 에서 )
  • format(value, context) : parse()에서 찾은 타입 사양을 적용하여 formatting context에 제공(내보내야) 합니다.
    std::format_to()에서 제공하는 반복자를 사용하여 간단히 추가할 수도 있습니다.
#include <format>

struct point {
	double x, y;
};

template <> struct std::formatter<point>{
// f - 부동 수숫점형,  e - 지수
    char presentation { 'f' };
    
    // 'f' 또는 'e'를 분석합니다.
    constexpr auto parse(format_parse_context& ctx) -> decltype(ctx.begin()){
    	// [ctx.begin(), ctx.end())는 형식 문자열의 일부를 포함하는 문자 범위입니다.
        // 예를 들어 std::format("{:f} - point of interest", point{1, 2}); 라면
        // 범위에는 "f} - point of interest" 가 포함됩니다.
        // 포매터는 '}' 또는 범위의 끝까지 지정자를 구문 분석해야 합니다.
        // 이 예에서 포매터는 'f' 지정자를 구문 분석하고 '}'를 가리키는 반복자를 반환해야 합니다.
        
        auto it{ ctx.begin() };
        auto end { ctx.end() };
        
        if(it != end && (*it == 'f' || *it == 'e')) presentation = *it++;
        
        if(it != end && *it != '}') throw std::format_error("invalid format");
        
        return it;
    }
    
    // 구분 분석 특수화를 사용하여 p의 타입을 지정합니다.
    // 이 포매터에 저장됩니다.
    template <typename FormatContext>
    auto format(const point& p, FormatContext& ctx) -> decltype(ctx.out()) {
        // ctx.out() 는 쓰기(write)에 사용된 output iterator 입니다.
    	return presentation == 'f'
        ? format_to(ctx.out(), "({:.1f}, {:.1f})", p.x, p.y)
        : format_to(ctx.out(), "({:.1e}, {:.1e})", p.x, p.y);
    }
};

이것을 std::format에 넘기게 되면 다음과 같은 결과가 나옵니다.

point p {1, 2};
std::string s { std::format("{:f}", p) };
// s == "(1.0, 2.0)"

 

상속 또는 구성(composition)을 통해 기존 포매터를 재사용할 수도 있습니다. 예를 들어

enum class color {red, green, blue};

template <> struct std::formatter<color>: formatter<string_view> {
  // formatter<string_view>를 상속받아 분석합니다.
  template <typename FormatContext>
  auto format(color c, FormatContext& ctx) {
    string_view name = "unknown";
    switch (c) {
    case color::red:   name = "red"; break;
    case color::green: name = "green"; break;
    case color::blue:  name = "blue"; break;
    }
    return formatter<string_view>::format(name, ctx);
  }
};

parse는 상속됐기 때문에 formatter<string_view>로 인해 모든 문자열 타입 사양을 인식합니다.

std::format("{:>10}", color::blue)
//"          blue"

 

클래스 계층에 대한 포매터를 작성할 수도 있습니다.

#include <type_traits>
#include <fmt/format.h>

struct A {
  virtual ~A() {}
  virtual std::string name() const { return "A"; }
};

struct B : A {
  virtual std::string name() const { return "B"; }
};

template <typename T>
struct std::formatter<T, std::enable_if_t<std::is_base_of<A, T>::value, char>> :
    std::formatter<std::string> {
  template <typename FormatCtx>
  auto format(const A& a, FormatCtx& ctx) {
    return std::formatter<std::string>::format(a.name(), ctx);
  }
};

int main() {
  B b;
  A& a = b;
  std::format("{}", a); // "B"
}

formatter가 분석 가능한 타입에 대한 특수화 및 암시적 변환을 모두 제공할 경우, 특수화가 우선시 됩니다.