기타 연산자
기타 연산자 (Other operators)
함수 호출(()), 콤마(,), 조건 연산자(?:) — 어디에도 딱 들어맞지 않는 연산자들을 한자리에서 다루는 주제예요. 특히 조건 연산자는 타입 결정 규칙이 자세하니 차근차근 보게 될 거예요.
본문
| 연산자 이름 | 문법 | 오버로드 가능 | 클래스 안 프로토타입 예 (T) | 클래스 밖 프로토타입 예 (T) |
|---|---|---|---|---|
| 함수 호출 (function call) | a(a1, a2) |
예 | R T::operator()(Arg1& a1, Arg2& a2, ...); |
N/A |
| 콤마 (comma) | a, b |
예 | T2& T::operator,(T2& b); |
T2& operator,(const T& a, T2& b); |
| 조건 연산자 (conditional) | a ? b : c |
아니요 | N/A | N/A |
함수 호출 연산자는 모든 객체에 함수 의미론을 제공해요.
조건 연산자(구어적으로 삼항 조건(ternary conditional)이라고 해요)는 첫 번째 표현식의 불리언 값을 확인하고, 그 결과 값에 따라 두 번째나 세 번째 표현식 중 하나를 평가하고 돌려줘요.
내장 함수 호출 연산자
함수 호출 표현식은 다음 형태를 가져요:
function(arg1, arg2, arg3, ...)
function— 함수 타입이나 함수 포인터 타입의 표현식arg1, arg2, arg3, ...— 비어 있을 수 있는 임의의 표현식 또는 중괄호로 감싼 이니셜라이저 목록(C++11 이후)의 목록. 단, 모호함을 피하려고 콤마 연산자는 최상위에 허용되지 않아요.
비멤버 함수나 정적 멤버 함수 호출에서 function은 함수를 가리키는 lvalue일 수 있고(이 경우 함수-포인터 변환이 억제돼요), 함수 포인터 타입의 prvalue일 수 있어요.
function이 지정하는 함수(또는 멤버) 이름은 오버로드될 수 있고, 어떤 오버로드를 호출할지는 오버로드 결정 규칙으로 정해요.
function이 멤버 함수를 지정하면 가상일 수 있고, 그 경우 런타임에 동적 디스패치를 써서 그 함수의 최종 오버라이더(final overrider)가 호출돼요.
각 함수 매개변수는 필요한 경우 암시적 변환 후에 해당하는 인자로 초기화돼요.
- 해당하는 인자가 없으면 해당 기본 인자(default argument)가 쓰이고, 그것도 없으면 프로그램은 ill-formed예요.
- 멤버 함수에 대한 호출이라면 현재 객체에 대한
this포인터가 그 함수가 기대하는this포인터로 명시적 캐스트처럼 변환돼요. - 각 매개변수의 초기화와 파괴는 함수 호출이 나타나는 full-expression의 문맥에서 일어나요. 이것은 예를 들어 매개변수의 생성자나 소멸자가 예외를 던지면, 호출된 함수의 함수 try 블록이 고려되지 않는다는 뜻이에요.
함수가 가변 인자(variadic) 함수라면, 줄임표(...) 매개변수와 일치하는 모든 인자에 기본 인자 승격(default argument promotion)이 적용돼요.
매개변수가 자신이 정의된 함수가 빠져나갈 때 파괴될지, 아니면 감싸는 full-expression의 끝에서 파괴될지는 구현 정의예요. 매개변수는 항상 구성된 역순으로 파괴돼요.
함수 호출 표현식의 반환 타입은 정적 바인딩을 써서 결정된(virtual 키워드를 무시하는) 선택된 함수의 반환 타입이에요. 실제로 호출되는 오버라이딩 함수가 다른 타입을 반환하더라도요. 덕분에 오버라이딩 함수가 기반 함수가 반환하는 타입에서 파생된 클래스에 대한 포인터·참조를 반환할 수 있어요. 즉 C++는 공변(covariant) 반환 타입을 지원해요. function이 소멸자를 지정하면 반환 타입은 void예요.
클래스 타입 X의 객체가 함수에 전달되거나 함수에서 반환될 때, X의 복사 생성자·이동 생성자·소멸자가 모두 trivial이거나 deleted이고 X에 최소한 하나의 삭제되지 않은 복사·이동 생성자가 있다면, 구현은 함수 매개변수나 결과 객체를 담는 임시 객체를 만들 수 있어요.
임시 객체는 각각 함수 인자나 반환 값으로 구성되고, 함수의 매개변수·반환 객체는 삭제되지 않은 trivial 생성자를 써서 임시 객체를 복사하는 것처럼 초기화돼요 (그 생성자가 접근 불가능하거나 오버로드 결정에서 그 객체의 복사·이동을 수행하도록 선택되지 않더라도요).
이 덕분에 std::complex나 std::span 같은 작은 클래스 타입 객체를 함수에 레지스터로 전달하거나 반환할 수 있어요. (C++17 이후)
함수 호출 표현식의 값 범주는, 함수가 lvalue 참조나 함수에 대한 rvalue 참조를 반환하면 lvalue, 객체에 대한 rvalue 참조를 반환하면 xvalue, 그 외에는 prvalue예요. 함수 호출 표현식이 객체 타입의 prvalue면, decltype의 피연산자(또는 decltype의 피연산자인 내장 콤마 연산자의 오른쪽 피연산자)로 쓰일 때(C++11 이후)를 제외하고는 완전 타입이어야 해요.
호출된 함수가 정상적으로 빠져나가면 함수의 모든 사후 조건(postcondition) 단언이 순서대로 평가돼요. 구현이 결과 값을 담는 임시 객체를 도입했다면, 각 사후 조건 단언의 평가 E에 대해:
E는 그 임시 객체들이나 결과 객체의 초기화와 불확정적으로 시퀀스돼요(indeterminately sequenced).E는 어떤 함수 매개변수의 파괴보다 먼저 시퀀스돼요. (C++26 이후)
함수 호출 표현식은 문법상 값 초기화 T(), 함수 스타일 캐스트 표현식 T(A1), 임시 객체의 직접 초기화 T(A1, A2, A3, ...)와 비슷해요. 여기서 T는 타입 이름이에요.
#include <cstdio>
struct S
{
int f1(double d)
{
return printf("%f \n", d); // variable argument function call
}
int f2()
{
return f1(7); // member function call, same as this->f1()
// integer argument converted to double
}
};
void f()
{
puts("function called"); // function call
}
int main()
{
f(); // function call
S s;
s.f2(); // member function call
}
출력:
function called
7.000000
f2() 안에서 f1(7)은 this->f1()과 같은 멤버 함수 호출이에요. 정수 인자 7이 double 매개변수에 맞게 변환돼서 7.000000이 출력돼요.
내장 콤마 연산자
콤마 표현식은 다음 형태를 가져요:
E1, E2
콤마 표현식 E1, E2에서 E1이 평가되고 결과는 무시돼요 (클래스 타입이면 감싸는 full-expression이 끝날 때까지 파괴되지 않아요). E1의 부작용은 E2의 평가가 시작되기 전에 완료돼요. (사용자 정의 연산자는 시퀀싱을 보장할 수 없다는 점에 주의하세요, until C++17).
콤마 표현식 결과의 타입·값·값 범주는 두 번째 피연산자 E2의 타입·값·값 범주와 정확히 같아요. E2가 임시 표현식(C++17 이후)이면 표현식의 결과는 그 임시 표현식이에요(C++17 이후). E2가 비트필드면 결과도 비트필드예요.
함수 인자 목록(f(a,b,c))이나 이니셜라이저 목록(int a[]={1,2,3}) 같은 다양한 콤마 구분 목록의 콤마는 콤마 연산자가 아니에요. 그런 문맥에서 콤마 연산자를 쓰려면 괄호로 감싸야 해요: f(a, (n++, n+b), c).
첨자 연산자의 두 번째(오른쪽) 인자로 괄호로 감싸지 않은 콤마 표현식을 쓰는 것은 폐기 예정이에요. 예를 들어
a[b,c]는 폐기 예정이고a[(b,c)]는 아니에요. (C++20 이후, until C++23)
괄호로 감싸지 않은 콤마 표현식은 첨자 연산자의 두 번째(오른쪽) 인자가 될 수 없어요. 예를 들어
a[b,c]는 ill-formed이거나a.operator[](b,c)와 동등해요. 콤마 표현식을 첨자로 쓰려면a[(b,c)]처럼 괄호가 필요해요. (C++23 이후)
#include <iostream>
int main()
{
// comma is often used to execute more than one expression
// where the language grammar allows only one expression:
// * in the third component of the for loop
for (int i = 0, j = 10; i <= j; ++i, --j)
// ^list separator ^comma operator
std::cout << "i = " << i << " j = " << j << '\n';
// * in a return statement
// return log("an error!"), -1;
// * in an initializer expression
// MyClass(const Arg& arg)
// : member{ throws_if_bad(arg), arg }
// etc.
// comma operators can be chained; the result of the last
// (rightmost) expression is the result of the whole chain:
int n = 1;
int m = (++n, std::cout << "n = " << n << '\n', ++n, 2 * n);
// m is now 6
std::cout << "m = " << (++m, m) << '\n';
}
출력:
i = 0 j = 10
i = 1 j = 9
i = 2 j = 8
i = 3 j = 7
i = 4 j = 6
i = 5 j = 5
n = 2
m = 7
콤마 연산자는 체인될 수 있고, 체인 전체의 결과는 가장 오른쪽 표현식의 결과예요. m = (++n, ..., ++n, 2*n)에서 마지막 2*n(n은 3)이 6이라 m은 6이 돼요. 여기서 주의할 점은 for (int i = 0, j = 10; ...)의 첫 번째 콤마는 목록 구분자(comma operator가 아님)이고, ++i, --j는 세 번째 성분의 콤마 연산자라는 거예요.
조건 연산자 (Conditional operator)
조건 연산자 표현식은 다음 형태를 가져요:
E1 ? E2 : E3
E1이 평가되고 bool로 문맥 변환돼요. 결과가 true면 조건 표현식의 결과는 E2의 값이고, 그 외에는 E3의 값이에요.
조건 표현식 E1 ? E2 : E3의 타입과 값 범주는 다음과 같이 결정돼요.
1단계 (Stage 1)
E2와 E3가 둘 다 void 타입이면, 결과는 void 타입의 rvalue(until C++11)prvalue(since C++11)예요.
E2와 E3 중 정확히 하나만 void 타입이면:
- 그
void타입 피연산자가 (가능하면 괄호로 감싼)throw표현식이면, 결과는 다른 피연산자의 타입과 값 범주를 가져요. 다른 피연산자가 비트필드면 결과도 비트필드예요. - 그 외에는 프로그램은 ill-formed예요.
E2와 E3 중 어느 것도 void 타입이 아니면 다음 단계로 진행해요.
2 + 2 == 4 ? throw 123 : throw 456; // the result is of type "void"
2 + 2 != 4 ? "OK" : throw "error"; // the result is of type "const char[3]"
// even if an exception is always thrown
2단계 (Stage 2)
E2나 E3가 같은 값 범주(until C++11)/glvalue 비트필드(now)이고 타입이 각각 cv1 T와 cv2 T면, 남은 과정에서 피연산자들은 cv가 cv1과 cv2의 합집합인 cv T 타입으로 간주돼요.
E2와 E3가 다른 타입이고 다음 조건 중 하나를 만족하면 3단계로 진행해요:
E2와E3중 적어도 하나가 (가능하면 cv 한정된) 클래스 타입E2와E3가 cv 한정을 제외하고 같은 타입의 같은 값 범주(until C++11)glvalue(now)
그 외에는 4단계로 진행해요.
3단계 (Stage 3)
TX 타입의 피연산자 표현식 X에서, Y의 타입 TY와 관련된 대상 타입으로의 암시적 변환 시퀀스를 형성하려고 시도해요:
Y가 lvalue면 대상 타입은TY&인데, 참조가 lvalue(until C++11)glvalue(since C++11)에 직접 바인딩될 때만 암시적 변환 시퀀스를 형성할 수 있어요.Y가 xvalue면 대상 타입은TY&&인데, 참조가 직접 바인딩될 때만 시퀀스를 형성할 수 있어요. (C++11 이후)Y가 rvalue(until C++11)prvalue(since C++11)이거나 위 변환 시퀀스 중 어떤 것도 형성될 수 없고,TX와TY중 적어도 하나가 (가능하면 cv 한정된) 클래스 타입이면:TX와TY가 같은 클래스 타입(cv 한정 무시)이면:TY가TX보다 cv 한정이 적어도 같으면 대상 타입은TY.- 그 외에는 변환 시퀀스가 형성되지 않아요.
- 그 외에
TY가TX의 기반 클래스면 대상 타입은TX의 cv 한정을 가진TY. - 그 외에 대상 타입은
Z의 타입. 여기서Z는Y에 lvalue-to-rvalue, 배열-포인터, 함수-포인터 표준 변환을 적용한 값이에요. - 그 외에는 변환 시퀀스가 형성되지 않아요.
이 과정으로 E2에서 E3를 위해 결정된 대상 타입으로 암시적 변환 시퀀스를 형성할 수 있는지, 그 반대도 가능한지 판단해요.
- 변환 시퀀스를 형성할 수 없으면 다음 단계로 진행해요.
- 정확히 하나의 변환 시퀀스를 형성할 수 있으면:
- 그 변환 시퀀스가 모호하면 프로그램은 ill-formed예요.
- 그 외에는 그 변환이 선택된 피연산자에 적용되고, 변환된 피연산자가 남은 과정에서 원래 피연산자 대신 쓰여요. 다음 단계로 진행해요.
- 두 시퀀스 모두 형성될 수 있으면 프로그램은 ill-formed예요.
struct A {};
struct B : A {};
using T = const B;
A a = true ? A() : T(); // Y = A(), TY = A, X = T(), TX = const B, Target = const A
4단계 (Stage 4)
E2와E3가 같은 타입의 lvalue면 결과는 그 타입의 lvalue이고,E2와E3중 적어도 하나가 비트필드면 결과도 비트필드예요. (until C++11)
E2와 E3가 같은 타입·같은 값 범주의 glvalue면 결과는 같은 타입·값 범주를 갖고, E2와 E3 중 적어도 하나가 비트필드면 결과도 비트필드예요. (C++11 이후)
그 외에는 결과는 rvalue(until C++11)prvalue(since C++11)예요.
E2와E3가 같은 타입이 아니고 둘 중 하나가 (가능하면 cv 한정된) 클래스 타입이면 5단계로 진행해요.- 그 외에는 6단계로 진행해요.
5단계 (Stage 5)
오버로드 결정이 내장 후보(built-in candidates)를 사용해 수행되어 피연산자들을 내장 타입으로 변환하려고 해요:
- 오버로드 결정이 실패하면 프로그램은 ill-formed예요.
- 그 외에는 선택된 변환이 적용되고 변환된 피연산자가 남은 과정에서 원래 피연산자 대신 쓰여요. 다음 단계로 진행해요.
6단계 (Stage 6)
배열-포인터와 함수-포인터 변환이 (가능하면 변환된) E2와 E3에 적용돼요. 그 변환 후에 다음 조건 중 적어도 하나를 만족해야 해요. 그렇지 않으면 프로그램은 ill-formed예요:
E2와E3가 같은 타입. 이 경우 결과는 그 타입이고, 선택된 피연산자로 복사 초기화(copy-initialization)돼요.E2와E3가 둘 다 산술·열거형 타입. 이 경우 일반 산술 변환이 적용되어 공통 타입으로 가져오고, 결과는 그 타입이에요.E2와E3중 적어도 하나가 포인터. 이 경우 lvalue-to-rvalue, 포인터, 함수 포인터(C++17 이후), 자격 변환이 적용되어 복합 포인터 타입으로 가져오고, 결과는 그 타입이에요.E2와E3중 적어도 하나가 멤버 포인터. 이 경우 lvalue-to-rvalue, 멤버 포인터, 함수 포인터(C++17 이후), 자격 변환이 적용되어 복합 포인터 타입으로 가져오고, 결과는 그 타입이에요.E2와E3가 둘 다 널 포인터 상수이고 적어도 하나가std::nullptr_t타입. 이 경우 결과는std::nullptr_t타입이에요. (C++11 이후)
int* intPtr;
using Mixed = decltype(true ? nullptr : intPtr);
static_assert(std::is_same_v<Mixed, int*>); // nullptr becoming int*
struct A
{
int* m_ptr;
} a;
int* A::* memPtr = &A::m_ptr; // memPtr is a pointer to member m_ptr of A
// memPtr makes nullptr as type of pointer to member m_ptr of A
static_assert(std::is_same_v<decltype(false ? memPtr : nullptr), int*A::*>);
// a.*memPtr is now just pointer to int and nullptr also becomes pointer to int
static_assert(std::is_same_v<decltype(false ? a.*memPtr : nullptr), int*>);
¹이런 조건 연산자는 C++14 이전의 C++11 constexpr 프로그래밍에서 흔히 쓰였어요. ²멤버 접근, 변환 함수가 deleted인지(C++11 이후), 피연산자가 비트필드인지 여부는 무시돼요.
조건 연산자의 결과 타입은 이진 타입 특성 std::common_type으로도 접근할 수 있어요. (C++11 이후)
오버로드 (조건)
승격된 산술 타입의 모든 쌍 L, R과, 포인터·멤버 포인터·scoped 열거형 타입인 모든 타입 P에 대해 다음 함수 시그니처가 오버로드 결정에 참여해요 (여기서 LR은 L과 R에 일반 산술 변환을 수행한 결과예요):
LR operator?:(bool, L, R);
P operator?:(bool, P, P);
연산자 ?:는 오버로드할 수 없어요. 이 함수 시그니처들은 오버로드 결정 목적으로만 존재해요.
#include <iostream>
#include <string>
struct Node
{
Node* next;
int data;
// deep-copying copy constructor
Node(const Node& other)
: next(other.next ? new Node(*other.next) : NULL)
, data(other.data)
{}
Node(int d) : next(NULL), data(d) {}
~Node() { delete next; }
};
int main()
{
// simple rvalue example
int n = 1 > 2 ? 10 : 11; // 1 > 2 is false, so n = 11
// simple lvalue example
int m = 10;
(n == m ? n : m) = 7; // n == m is false, so m = 7
//output the result
std::cout << "n = " << n << "\nm = " << m;
}
출력:
n = 11
m = 7
(n == m ? n : m) = 7은 E2와 E3가 같은 타입의 lvalue(int lvalue)라서 조건 표현식 결과가 lvalue가 되고, 그래서 할당의 왼쪽 피연산자로 쓸 수 있어요. n == m이 false라 m = 7이 돼요. 반면 1 > 2 ? 10 : 11은 rvalue 피연산자라 prvalue 결과가 나와서 n = 11이 돼요.
표준 라이브러리
표준 라이브러리의 많은 클래스가 함수 객체(functor)로 쓰이도록 operator()를 오버로드해요. 대표적으로:
std::default_delete— 객체나 배열 삭제std::plus,std::minus,std::multiplies,std::divides,std::modulus,std::negate— 산술 연산 함수 객체std::equal_to,std::not_equal_to,std::greater,std::less,std::greater_equal,std::less_equal— 비교 함수 객체std::logical_and,std::logical_or,std::logical_not,std::bit_and,std::bit_or,std::bit_xor— 논리·비트 함수 객체std::unary_negate,std::binary_negate— 저장된 술어의 논리 보수std::reference_wrapper,std::function,std::move_only_function,std::copyable_function— 저장된 함수 호출std::coroutine_handle— 코루틴 재개std::locale— collate 패싯으로 문자열 사전순 비교std::map·std::multimap의value_compare— 값 비교std::packaged_task— 함수 실행std::linear_congruential_engine,std::uniform_int_distribution등 — 난수 생성
콤마 연산자는 표준 라이브러리의 어떤 클래스에서도 오버로드되지 않아요. boost 라이브러리는 boost.assign, boost.spirit 등에서 operator,를 사용하고, 데이터베이스 접근 라이브러리 SOCI도 operator,를 오버로드해요.
결함 보고 (Defect reports)
다음은 이전에 발행된 C++ 표준에 소급 적용된, 동작을 바꾸는 결함 보고예요.
| DR | 적용 대상 | 발행 당시 동작 | 올바른 동작 |
|---|---|---|---|
| CWG 446 | C++98 | 조건 연산자에서 lvalue-to-rvalue 변환을 위해 임시 객체를 만드는지 불특정이었음 | 연산자가 클래스 rvalue를 반환하면 항상 임시 객체를 만듦 |
| CWG 462 | C++98 | 콤마 연산자의 두 번째 피연산자가 임시 객체일 때, 콤마 표현식 결과를 참조에 바인딩하면 수명이 연장되는지 불특정이었음 | 이 경우 콤마 표현식의 결과는 그 임시 객체 (따라서 수명 연장됨) |
| CWG 587 | C++98 | 조건 연산자의 두 번째·세 번째 피연산자가 cv 한정만 다른 같은 타입의 lvalue일 때, 클래스 타입이면 lvalue, 그 외에는 rvalue였음 | 이 경우 항상 lvalue |
| CWG 1029 | C++98 | 소멸자 호출의 타입이 불특정이었음 | void로 지정 |
| CWG 1550 | C++98 | 다른 피연산자가 non-void일 때 조건 표현식에서 괄호로 감싼 throw 표현식을 허용하지 않았음 |
허용 |
| CWG 1560 | C++98 | 조건 연산자의 void 피연산자가 다른 피연산자에 불필요한 lvalue-to-rvalue 변환을 일으켜 항상 rvalue가 됐음 |
void가 있는 조건 표현식은 lvalue가 될 수 있음 |
| CWG 1642 | C++98 | 함수 호출 표현식의 function이 함수 포인터 lvalue일 수 있었음 |
허용 안 함 |
| CWG 1805 | C++98 | 암시적 변환 시퀀스의 대상 타입을 결정할 때 Y를 Z로 변환하는 방식이 불명확했음 |
명확히 함 |
| CWG 1895 | C++98/C++11 | deleted(C++11)나 접근 불가능한(C++98) 변환 함수가 조건 표현식의 변환을 막는지, 파생 클래스에서 기반 클래스 prvalue로의 변환이 고려되는지 불명확했음 | 오버로드 결정처럼 처리 |
| CWG 1932 | C++98 | 조건 표현식에서 같은 타입의 비트필드가 빠져 있었음 | 기반 타입으로 처리 |
| CWG 2226 | C++11 | 조건 연산자의 다른 피연산자 대상 타입을 결정할 때, 피연산자가 lvalue면 참조가 xvalue에 바인딩될 수 없었음 | 허용 |
| CWG 2283 | C++17 | 함수 호출 연산자의 타입 완전성 요구사항이 P0135R1로 우연히 제거됨 | 요구사항 복원 |
| CWG 2321 | C++98 | 조건 연산자의 다른 피연산자 대상 타입을 결정할 때 파생 클래스 타입이 cv 한정이 덜한 기반 클래스 타입으로 변환될 수 없었음 | 파생 클래스 피연산자의 cv 한정을 가진 기반 클래스 타입으로 변환 허용 |
| CWG 2715 | C++98 | 각 매개변수의 초기화·파괴가 호출 함수의 문맥에서 일어나는 것으로 되어 있었는데, 그런 함수가 존재하지 않을 수도 있었음 | 감싸는 full-expression의 문맥에서 발생 |
| CWG 2850 | C++98 | 매개변수의 파괴 순서가 불명확했음 | 명확히 함 |
| CWG 2865 | C++98 | TX와 TY가 같은 클래스 타입이고 TX가 TY보다 cv 한정이 많을 때, prvalue Y로부터 암시적 변환 시퀀스를 여전히 형성할 수 있었음 |
이 경우 변환 시퀀스가 형성되지 않음 |
| CWG 2906 | C++98 | 조건 연산자의 rvalue 결과 경우에 lvalue-to-rvalue 변환이 무조건 적용됐음 | 일부 경우에만 적용 |
¹예를 들어 네임스페이스 스코프 변수의 이니셜라이저에서 함수를 호출할 수 있는데, 그런 문맥에는 "호출 함수"가 없어요.
더 알아보기 (Learn more)
- 연산자 우선순위 — 각 연산자의 우선순위와 결합성을 정리한 표예요.
- 연산자 오버로딩 — 사용자 정의 타입에서 연산자를 재정의하는 방법이에요.
std::common_type— 조건 연산자의 결과 타입을 결정하는 데 쓰이는 타입 특성이에요.