#18. [모던 C++] 개선된 컴파일 타임 프로그래밍(constexpr)(C++11, C++14, C++17, C++20)
- [MEC++#15] 가능하면 항상 constexpr을 사용하라.(constexpr 함수 참고)
- constexpr 함수는 컴파일 타임, 런타임 모두 사용할 수 있다.
- (C++11~) constexpr이 추가되어 컴파일 타임 프로그래밍이 강화됐습니다.
- (C++14~) constexpr 함수 제약이 완화되어 지역 변수, 2개 이상의 리턴문,
if()
,for()
,while()
등을 사용할 수 있습니다.- (C++17~) if constexpr이 추가되어 조건에 맞는 부분만 컴파일하고, 그렇지 않은 부분은 컴파일에서 제외할 수 있습니다.
- (C++20~) consteval 함수가 추가되어 컴파일 타임 함수로만 동작할 수 있습니다.
- (C++20~) constinit가 추가되어 전역 변수, 정적 전역 변수, 정적 멤버 변수를 컴파일 타임에 초기화할 수 있습니다.
- (C++20~) constexpr 함수 제약 완화가 한차례 더 보강되어 가상 함수, dynamic_cast, typeid(), 초기화되지 않은 지역 변수, try-catch(), 공용체 멤버 변수 활성 전환,
asm
등을 사용할 수 있습니다.
개요
템플릿 메타 프로그래밍에서 언급한 것처럼 컴파일 타임에 여러가지 프로그래밍이 가능합니다. 하지만, 템플릿으로 우회하며 작성하다보니 상당히 난해했는데요,
C++11 부터는 constexpr 이용해 컴파일 타임 상수 표현식을 지정할 수 있어 컴파일 타임 프로그래밍 환경이 좀더 쉬워졌습니다.
컴파일 타임 상수
2 + 3
과 같은 표현식은 컴파일 타임에 컴파일러가 계산해서 5
라고 알 수 있습니다. 이렇게 컴파일 타임에 결정될 수 있는 표현식을 컴파일 타임 상수 표현식이라 합니다.
컴파일 타임 상수는 읽기 전용 메모리인 코드 세그먼트에 할당되므로 예외에 안전합니다. 할 수 있다면 최대한 컴파일 타임 상수로 만드는게 좋습니다.
컴파일 타임 상수는 다음과 같이
-
열거형의 값이나
-
비타입 템플릿 인자 전달이나
-
static_assert()로 알 수 있습니다.(static_assert() 참고)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<int val>
class T {
public:
int operator ()() {return val;}
};
const int size{20}; // 상수 입니다.
// 열거형 상수
enum class MyEnum_11 {Val = size}; // (O) size는 컴파일 타임 상수 입니다.
// 비타입 템플릿 인자
T<size> t; // (O) size는 컴파일 타임 상수 입니다.
// static_assert
static_cast(size == 20); // (O) size는 컴파일 타임 상수 입니다.
constexpr
constexpr은 주어진 변수를 컴파일 타임 상수로 지정합니다.
const int
를 사용하면 컴파일 타임 상수로 취급되는되요,
1
2
3
const int size{20}; // 상수 입니다.
static_assert(size == 20); // (O) size는 컴파일 타임 상수 입니다.
하지만, 변수로부터 const int
를 초기화 하면, 런타임 상수이지, 컴파일 타임 상수가 아니어서 컴파일 오류가 납니다.
1
2
3
4
int a{20};
const int size{a}; // 변수로부터 const int를 초기화 해서 런타임 상수 입니다.
static_assert(size == 20); // (X) 컴파일 오류. size는 런타임 상수 입니다.
constexpr은 좀더 명시적으로 컴파일 타임 상수임을 알려 줍니다.
1
2
3
constexpr int size_11{20}; // 컴파일 타임 상수 입니다.
static_assert(size_11 == 20); // (O)
constexpr은 const int
와 달리 변수를 대입하면 컴파일 오류가 발생합니다.
1
2
int a{20};
constexpr int size_11{a}; // (X) 컴파일 오류. 상수를 대입해야 합니다.
constexpr 함수
기존에는 컴파일 타임 함수가 없었고, 함수처럼 동작하도록 다음처럼 템플릿을 활용했는데요(템플릿 메타 프로그래밍 참고),
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Factorial n -1 을 재귀 호출합니다.
template<unsigned int n>
struct Factorial {
enum {
Val = n * Factorial<n-1>::Val
};
};
// 0일때 특수화 버전. 더이상 재귀 호출을 안합니다.
template<>
struct Factorial<0> {
enum {
Val = 1
};
};
// 컴파일 타임에 계산된 120이 Val에 대입됩니다.
enum class MyEnum {Val = Factorial<5>::Val};
EXPECT_TRUE(static_cast<int>(MyEnum::Val) == 1 * 2 * 3 * 4 * 5);
C++11 부터는 constexpr을 이용하여 암시적으로 인라인 함수인 컴파일 타임 함수를 만들 수 있습니다.
- 컴파일 타임 상수를 전달하면 컴파일 타임 함수로 동작하고,
-
일반 변수를 전달하면, 일반 함수들처럼 런타임 함수로 동작합니다.
(C++20~) consteval 함수가 추가되어 컴파일 타임 함수로만 동작할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
constexpr int Factorial_11(int val) {
// 마지막으로 1에 도달하면 재귀호출 안함
return val == 1 ? val : val * Factorial_11(val - 1);
}
// 컴파일 타임에 계산된 120입니다.
static_assert(Factorial_11(5) == 1 * 2 * 3 * 4 * 5);
// 변수를 전달하면, 일반 함수처럼 동작합니다.
int val{5};
int result{Factorial_11(5)};
런타임에 했던 작업을 최대한 컴파일 타임으로 전환하여 런타임 성능을 향상시킬 수 있는 획기적인 기능이지만 함수 구현에 제약이 좀 많습니다.
오로지 1개의 명령문으로만 작성해야 합니다.(제약 조건은 점진적으로 완화되고 있습니다. constexpr 함수 제약 완화를 참고하세요.)
- 1개의 리턴문을 사용합니다.
- 조건식 대신 조건 연산자(
a ? b : c
)를 사용합니다. - 반복문은 재귀 호출을 이용하여 구현합니다.
(C++14~) constexpr 함수 제약이 완화되어 지역 변수, 2개 이상의 리턴문,
if()
,for()
,while()
등을 사용할 수 있습니다.
(C++20~) constexpr 함수 제약 완화가 한차례 더 보강되어 가상 함수, dynamic_cast, typeid(), 초기화되지 않은 지역 변수, try-catch(), 공용체 멤버 변수 활성 전환,asm
등을 사용할 수 있습니다.
(C++20~) 유틸리티의 constexpr 지원이 개선되어swap()
함수도 constexpr 함수로 변경되었습니다.
(C++20~) 컨테이너 멤버 함수의 constexpr 지원이 개선되어 대부분의 멤버 함수들이 constexpr 함수로 변경되었습니다.
(C++20~) vector와 string의 constexpr 지원이 개선되어 vector와 string이 constexpr 컨테이너로 변경되었습니다.
(C++20~) 대부분의 알고리즘에서 constexpr을 지원합니다.
(C++20~) complex의 constexpr 지원이 개선되었습니다.
(C++20~) is_constant_evaluated()가 추가되어 constexpr 함수가 컴파일 타임 함수인지 런타임 함수인지 검사할 수 있습니다.
constexpr 생성자
constexpr은 리터럴 타입만 사용할 수 있습니다.
그러다 보니, 사용자 정의 생성자, 사용자 정의 소멸자가 없으며 모든 멤버 변수가 public
인 집합 타입인 구조체/클래스인 경우에만 사용할 수 있는데요, 다행히, constexpr 생성자를 이용하여 리터럴 타입인 구조체/클래스를 직접 만들어 사용할 수 있습니다.
다음 Area_11
클래스는 private
멤버 변수와 생성자를 갖고 있지만, constexpr 생성자를 사용하여 리터럴 타입으로 동작합니다.
-
Area_11(int x, int y)
에 컴파일 상수를 전달하면,Area_11
도 컴파일 상수로 동작합니다. -
Area_11
이 컴파일 타임 상수로 생성되었다면,GetVal_11()
은 컴파일 타임 상수끼리의 연산이므로 컴파일 타임 함수가 될 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Area_11 {
private:
int m_X;
int m_Y;
public:
constexpr Area_11(int x, int y) : // 컴파일 타임 상수로 사용 가능
m_X(x),
m_Y(y) {}
constexpr int GetVal_11() const {return m_X * m_Y;}
};
constexpr Area_11 area(2, 5); // 컴파일 타임 상수로 정의
// 컴파일 타임에 계산된 면적이 Val에 대입됩니다.
enum class MyEnum_11 {Val = area.GetVal_11()}; // constexpr 함수 호출
EXPECT_TRUE(static_cast<int>(MyEnum_11::Val) == 2 * 5);
만약 다음의 x
와 같이 변수를 전달한다면 constexpr을 사용할 수 없습니다. 런타임 함수로 사용해야 합니다.
1
2
3
int x = 10;
// constexpr Area_11 area{x, 5}; // (X) 컴파일 오류. x는 컴파일 타임 상수가 아닙니다.
Area_11 area{x, 5}; // 런타임 함수로 동작합니다.
GetVal_11()
와 같은 Setter 함수들은 다음 제약 조건 때문에 만들기 어렵습니다.(제약 조건은 점진적으로 완화되고 있습니다. constexpr 함수 제약 완화를 참고하세요.)
-
constexpr 함수는 리터럴 타입만 리턴합니다. 그런데
void
가 리터럴이 아닙니다.(C++14 부터는void
도 리터럴 타입이 되어 가능합니다.) -
constexpr 함수는 암묵적으로 상수 멤버 함수이고
operator =
등이 아예 허용되지 않습니다. mutable도 못씁니다.
다행히 C++14 부터는 상기 제약이 완화되어 다음처럼 Setter 함수를 만들 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Area_11 {
private:
int m_X;
int m_Y;
public:
constexpr Area_11(int x, int y) : // 컴파일 타임 상수로 사용 가능
m_X{x},
m_Y{y} {}
constexpr int GetVal_11() const {return m_X * m_Y;}
// C++14 부터는 void를 리턴할 수 있고, constexpr개체에서도 멤버 변수의 값을 수정할 수 있습니다.
constexpr void SetX_14(int val) {m_X = val;}
constexpr void SetY_14(int val) {m_Y = val;}
};
(C++14~) constexpr 함수 제약 완화
constexpr 함수는 기본적으로 리터럴 타입만 리턴할 수 있고, 그외 스펙들은 조금씩 개선되고 있습니다.
C++11 에서는 지역 변수나 제어문도 사용할 수 없어서 상당히 제한적이었으나, 점점 개선되고 있습니다. 자세한 내용은 cppreference.com을 참고하시기 바랍니다.
항목 | C++11 | C++14 | C++17 | C++20 |
---|---|---|---|---|
리터럴 타입 외의 리턴 | X | X | X | X |
조건 연산자 | O | O | O | O |
static_assert() | O | O | O | O |
typedef, using | O | O | O | O |
constexpr 함수 호출 | O | O | O | O |
초기화된 지역 변수 정의 | X | O | O | O |
초기화되지 않은 지역 변수 정의 | X | X | X | O |
1개의 리턴문 | O | O | O | O |
2개 이상의 리턴문 | X | O | O | O |
if , for , while |
X | O | O | O |
goto , switch |
X | X | X | X |
void 사용 | X | O | O | O |
const 자유도 | X | O | O | O |
try-catch() | X | X | X | O |
throw |
X | X | X | X |
가상 함수 | X | X | X | O |
dynamic_cast | X | X | X | O |
typeid() | X | X | X | O |
공용체 멤버 변수의 활성 전환 | X | X | X | O |
asm |
X | X | X | O |
다음은 C++14 기준에 맞춰서 좀더 일반적인 형태로 Factorial_14()
를 구현한 예입니다. 지역 변수, 2개 이상의 리턴문, if()
, for()
등을 사용할 수 있어서 코딩 자유도가 높아졌습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
constexpr int Factorial_14(int val) {
int result{1}; // 초기화된 지역 변수 정의
if (val < 1) {
return 1; // 2개 이상의 리턴문
}
for (int i{val}; 0 < i; --i) { // 제어문
result *= i;
}
return result;
}
// 컴파일 타임에 계산된 120입니다.
static_assert(Factorial_14(5) == 1 * 2 * 3 * 4 * 5);
(C++20~) constexpr 함수 제약 완화가 한차례 더 보강되어 가상 함수, dynamic_cast, typeid(), 초기화되지 않은 지역 변수, try-catch(), 공용체 멤버 변수 활성 전환,
asm
등을 사용할 수 있습니다.
(C++17~) if constexpr
조건식에 따라 컴파일하거나 컴파일하지 않습니다.
템플릿 메타 프로그래밍에서 소개된 다음 코드는 if()
을 통해 new T(*ptr);
와 ptr->Clone();
을 호출하기 때문에, 이 두가지가 모두 가능한 것만 컴파일할 수 있었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class ICloneable {
private:
ICloneable(const ICloneable& other) {}
ICloneable& operator =(const ICloneable& other) {return *this;}
protected:
ICloneable() {}
~ICloneable() {}
public:
virtual ICloneable* Clone() const = 0;
};
// D가 B를 상속하였는지 검사하는 템플릿
template<typename D, typename B>
class IsDerivedFrom {
class No {};
class Yes {No no[2];};
static Yes Test(B*);
static No Test(...);
public:
enum {Val = sizeof(Test(static_cast<D*>(0))) == sizeof(Yes)};
};
template<typename T>
class CloneTraits {
public:
static T* Clone(const T* ptr) {
if (ptr == NULL) {
return NULL;
}
if (!IsDerivedFrom<T, ICloneable>::Val) {
return new T(*ptr);
}
else {
return ptr->Clone(); // (X) 컴파일 오류. int에 Clone() 함수가 없습니다.
}
}
};
int val;
int* ptr{CloneTraits<int>::Clone(&val)}; // (X) 컴파일 오류. int에 Clone() 함수가 없습니다.
delete ptr;
따라서, CloneTraits
를 구현할때 컴파일 타임 프로그래밍을 위해 if()
문을 사용하지 말고, CloneTag
를 이용하여 함수 오버로딩하라 소개했는데요,(템플릿 메타 프로그래밍의 CloneTraits 구현에서 템플릿 메타 프로그래밍을 이용하는 방법 참고)
C++17 부터는 if constexpr이 추가되어 조건에 맞는 부분만 컴파일하고, 그렇지 않은 부분은 컴파일에서 제외할 수 있습니다.
즉, 다음과 같이 작성하면,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
class CloneTraits {
public:
static T* Clone_17(const T* ptr) {
if (ptr == NULL) {
return NULL;
}
// 조건에 맞는 부분만 컴파일 합니다.
if constexpr (!IsDerivedFrom<T, ICloneable>::Val) {
return new T(*ptr);
}
else {
return ptr->Clone();
}
}
};
int val;
int* ptr{CloneTraits<int>::Clone_17(&val)}; // (O)
delete ptr;
다음과 동등하기 때문에 컴파일되고 잘 작동합니다.
1
2
3
4
5
6
7
8
9
10
static T* Clone_17(const T* ptr) {
if (ptr == NULL) {
return NULL;
}
return new T(*ptr); // return ptr->Clone();은 조건에 맞지 않으므로 컴파일 타임에 제외되었습니다.
}
int val;
int* ptr{CloneTraits<int>::Clone_17(&val)}; // (O)
delete ptr;
(C++20~) consteval 함수
- 컴파일 타임 상수를 전달하면 컴파일 타임 함수로 동작하고,
- 일반 변수를 전달하면, 일반 함수들처럼 런타임 함수로 동작했는데요(constexpr 함수 참고),
C++20 부터는 consteval 함수가 추가되어 컴파일 타임 함수로만 동작할 수 있습니다. 이렇게 컴파일 타임에만 동작하는 함수를 즉시 함수(immediate function)라고 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
constexpr int Add_14(int a, int b) {return a + b;}
consteval int Add_20(int a, int b) {return a + b;}
enum class MyEnum_11 {Val = Add_14(1, 2)}; // 컴파일 타임 함수로 사용
EXPECT_TRUE(static_cast<int>(MyEnum_11::Val) == 3);
enum class MyEnum_11 {Val = Add_20(1, 2)}; // 컴파일 타임 함수로 사용
EXPECT_TRUE(static_cast<int>(MyEnum_11::Val) == 3);
int a{10};
int b{20};
EXPECT_TRUE(Add_14(a, b) == 30); // 런타임 함수로 사용
EXPECT_TRUE(Add_20(a, b) == 30); // (X) 컴파일 오류. 런타임 함수로 사용할 수 없습니다.
consteval 함수도 constexpr 함수와 같이 제약이 있으며, constexpr 함수 제약 완화를 참고하시기 바랍니다.
(C++20~) constinit 변수
전역 변수, 정적 전역 변수, 정적 멤버 변수는 프로그램이 시작될 때 생성되고, 프로그램이 종료될 때 해제됩니다. 하지만 생성되는 시점이 명확하지 않아 링크 순서에 따라 다음 예의 g_B
가 0
이 되거나 10
이 됩니다. 따라서, 함수내 정적 지역 변수를 사용하라고 가이드 했었죠.(정적 변수의 초기화 순서 참고)
1
2
3
// Test_A.cpp에서
int f() {return 10;}
int g_A = f(); // 전역 변수. 런타임에 f() 함수를 이용해서 초기화 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Test_B.cpp에서
#include <iostream>
extern int g_A;
int g_B = g_A; // (△) 비권장. 컴파일 단계에선 일단 0으로 초기화 하고, 나중에 링크 단계에서 g_A의 값으로 초기화 합니다.
// g_A가 초기화 되었다는 보장이 없기에 링크 순서에 따라 0 또는 10이 됩니다.
int main() {
std::cout << "g_A : " << g_A << std::endl;
std::cout << "g_B : " << g_B << std::endl; // (△) 비권장. 0이 나올 수도 있고, 10이 나올 수도 있습니다.
return 0;
}
C++20 부터는 constinit가 추가되어 전역 변수, 정적 전역 변수, 정적 멤버 변수를 컴파일 타임에 초기화할 수 있습니다. 따라서, 전역 변수, 정적 전역 변수, 정적 멤버 변수의 생성 시점이 명확하지 않은 문제가 해결 됩니다.(하지만, 이 방법 보다는 여전히 함수내 정적 지역 변수를 사용하는게 좋아 보입니다.)
상기 코드에 constinit을 사용하면, 링크 타임이 아니라 컴파일 타임에 초기화 되므로, 링크 순서에 따라 g_B
가 0
이 되거나 10
이 되는 문제를 수정할 수 있습니다.
1
2
3
// Test_A.cpp에서
constexpr int f_11() {return 10;} // 컴파일 타임 상수 입니다.
constinit int g_A_20 = f_11(); // 컴파일 타임에 초기화 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
// Test_B.cpp에서
#include <iostream>
extern constinit int g_A_20;
int g_B_20 = g_A_20; // 컴파일 타임에 초기화 됩니다.
int main() {
std::cout << "g_A_20 : " << g_A_20 << std::endl;
std::cout << "g_B_20 : " << g_B_20 << std::endl; // 항상 10이 나옵니다.
return 0;
}
컴파일 타임에 초기화된다는 측면에서 constexpr과 유사하지만, 다음과 같은 차이가 있습니다.
항목 | constexpr | constinit |
---|---|---|
의미 | 컴파일 타임 상수 입니다. | 컴파일 타임에 초기화되어야 합니다. |
상수성 | const입니다. | constinit의 이름 때문에 오해하기 쉬운데, const가 아닙니다. 상수로 만드려면, constinit const int 와 같이 const를 별도로 붙여야 합니다. |
지역 변수 적용 | 지원합니다. | 지원하지 않습니다. 전역 변수, 정적 전역 변수, 정적 멤버 변수에만 사용 가능합니다. |
다음은 constinit를 전역 변수, 정적 전역 변수, 정적 멤버 변수에 사용한 예입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
constexpr int f_11() {return 10;} // 컴파일 타임 함수 입니다.
// constinit int 로 받을 수도 있지만, s_Val_20과 s_m_Val_20에 대입하기 위해 const를 붙였습니다.
constinit const int g_Val_20 = f_11(); // constinit여서 컴파일 타임에 초기화 되어야 합니다.
// g_Val_20은 컴파일 타임 const 상수 입니다.
constinit static int s_Val_20 = g_Val_20; // constinit여서 컴파일 타임에 초기화 되어야 합니다.
class T_20 {
public:
// C++17 부터 인라인 변수를 이용하여 정적 멤버 변수를 멤버 선언부에서 초기화할 수 있습니다.
constinit static inline int s_m_Val_20 = g_Val_20; // constinit여서 컴파일 타임에 초기화 되어야 합니다.
};
(C++20~) constexpr 함수 제약 완화
C++14 에서 constexpr 함수의 제약이 완화되었으나((C++14~) constexpr 함수 제약 완화 참고),
C++20 에서는 constexpr 함수 제약 완화가 한차례 더 보강되어 가상 함수, dynamic_cast, typeid(), 초기화되지 않은 지역 변수, try-catch(), 공용체 멤버 변수 활성 전환, asm
등을 사용할 수 있습니다.
다음예에서 가상 함수를 오버라이딩한 Func
함수를 컴파일 타임 상수로 사용할 수 있고, 이를 Base*
를 통해 가상 함수를 호출하면, 런타임에 동작하는 걸 확인 할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
virtual int Func() const {return 0;}
};
class Derived_20 : public Base {
public:
constexpr virtual int Func() const override {return 1;} // C++17 이하에서는 컴파일 오류가 발생했습니다.
};
constexpr Derived_20 a_20;
enum class MyEnum_11 {Val = a_20.Func()}; // 컴파일 타임 상수
const Base* ptr = &a_20;
EXPECT_TRUE(ptr->Func() == 1); // 부모 개체의 포인터로 런타임에 가상 함수를 호출할 수 있습니다.
// static_assert(ptr->Func()); // (X) 컴파일 오류. 컴파일 타임 상수가 아닙니다.
또한 dynamic_cast와 typeid()를 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
virtual int Func() const {return 0;}
};
class Derived_20 : public Base {
public:
constexpr virtual int Func() const override {return 1;}
};
constexpr int Func_20() {
Derived_20 d;
Base* base = &d;
Derived_20* derived_20 = dynamic_cast<Derived_20*>(base); // dynamic_cast를 사용할 수 있습니다.
typeid(base); // typeid를 사용할 수 있습니다.
return 1;
}
그외에 초기화되지 않은 지역 변수 정의, try-catch(), asm
(인라인 어셈블리)이 허용됩니다.
1
2
3
4
5
6
constexpr void Func_20() {
int a; // 초기화되지 않은 지역 변수
try {} // try-catch
catch (...) {}
}
기존에는 constexpr 함수에서 공용체 멤버 변수의 활성을 전환하면 컴파일 오류가 있었는데요, C++20 부터는 허용합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
union MyUnion {
int i;
float f;
};
constexpr int Func_20() {
#if 202002L <= __cplusplus // C++20~
MyUnion myUnion{};
myUnion.i = 3;
// (X) ~C++20 컴파일 오류. change of the active member of a union from 'MyUnion::i' to 'MyUnion::f'
// (O) C++2O~
myUnion.f = 1.2f; // 멤버 변수 활성 전환을 허용합니다.
#endif
return 1;
}
static_assert(Func_20());
댓글남기기