C++ explicit 키워드: 암시적 변환 막기, explicit operator bool, C++20 explicit(bool)

이 글의 핵심

String s = 10; 같은 코드가 컴파일된다면 단일 인자 생성자가 암시적 변환 경로를 열어 두고 있다는 뜻입니다. 편리해 보이지만 함수 인자로 잘못된 타입이 들어가도 에러가 나지 않는 문제가 있어, 변환 의도가 명확한 경우가 아니면 explicit를 기본으로 두는 편이 안전합니다. 사용 결정 플로우와 코드 가이드라인, 버전별 지원 차이까지 함께 정리했습니다.

explicit이란?

explicit은 생성자·변환 연산자에 붙여 암시적 변환을 막는 키워드입니다. 복사 초기화 = expr에서 의도치 않은 변환이 일어나지 않게 할 때 쓰며, 스마트 포인터 생성자도 대부분 explicit입니다.

explicit 유무 비교

구분explicit 없음explicit 있음
복사 초기화String s = 10; ✅String s = 10; ❌
직접 초기화String s(10); ✅String s(10); ✅
중괄호 초기화String s{10}; ✅String s{10}; ✅
함수 인자func(10); ✅func(10); ❌
반환 값return 10; ✅return 10; ❌
static_caststatic_cast<String>(10) ✅static_cast<String>(10) ✅
복사 리스트 초기화String s = {10}; ✅String s = {10}; ❌
class String {
public:
    String(int size) {}  // 암시적 변환 허용
};

String s = 10;  // OK: int -> String

// explicit 사용
class String {
public:
    explicit String(int size) {}
};

// String s = 10;  // 에러
String s(10);  // OK: 명시적 생성

표에서 기억할 기준은 하나입니다. ”=” 기호나 함수 인자·반환처럼 컴파일러가 스스로 변환을 끼워 넣어야 하는 문맥(복사 초기화)에서는 explicit 생성자가 후보에서 빠지고, 괄호나 중괄호로 타입 이름을 직접 적는 문맥(직접 초기화)에서는 쓸 수 있습니다. String s = {10};처럼 등호와 중괄호를 함께 쓰는 복사 리스트 초기화는 중괄호가 있어도 “복사” 쪽이라 explicit 생성자가 선택되면 에러가 납니다. 정확히는 후보에서 빠지는 것이 아니라 오버로드 결정에는 참여한 뒤 선택되면 에러라서, 에러 메시지가 converting to 'String' from initializer list would use explicit constructor처럼 나옵니다.

변환 과정 다이어그램

graph LR
    A[int 값] -->|explicit 없음| B[암시적 변환]
    B --> C[생성자 호출]
    C --> D[객체 생성]
    
    A -->|explicit 있음| E{초기화 방식}
    E -->|복사 초기화 =| F[컴파일 에러]
    E -->|직접 초기화 | G[생성자 호출]
    G --> D

생성자에 explicit

문제 상황

graph TD
    A[함수 호출: process 10] --> B{생성자 explicit?}
    B -->|없음| C[암시적 변환 허용]
    C --> D[Array 10 임시 객체 생성]
    D --> E[함수 실행]
    E --> F[의도하지 않은 동작]
    
    B -->|있음| G[컴파일 에러]
    G --> H[명시적 변환 필요]
    H --> I[process Array 10]
    I --> J[의도 명확]
class Array {
public:
    // ❌ 암시적 변환 허용
    Array(int size) {}
};

void process(Array arr) {}

int main() {
    process(10);  // int -> Array (의도하지 않음)
}

// ✅ explicit 사용
class Array {
public:
    explicit Array(int size) {}
};

// process(10);  // 에러
process(Array(10));  // OK

이 예제가 위험한 이유는 컴파일러가 아무 경고 없이 임시 Array 객체를 만들어 넘긴다는 점입니다. process가 “원소 10개짜리 배열”을 받는 함수라면 process(10)은 정수 10 하나를 원소로 넘기려던 호출자의 의도와 전혀 다른 일을 합니다. 코드 리뷰에서도 process(10)만 보면 이상한 점이 없어 보여서 잡기 어렵습니다. C++ Core Guidelines(C.46)가 “단일 인자 생성자는 기본적으로 explicit으로 선언하라”고 권하는 이유가 이것이며, clang-tidy의 google-explicit-constructor 검사로 누락을 자동으로 찾을 수 있습니다. 기본값이 있는 매개변수 때문에 인자 하나로 호출 가능한 생성자(Array(int size, int init = 0))도 같은 변환 경로를 만든다는 점을 놓치기 쉽습니다.

실전 예시

예시 1: 기본 사용

class Vector {
    double* data;
    size_t size;
    
public:
    explicit Vector(size_t s) : size(s) {
        data = new double[size];
    }
    
    ~Vector() {
        delete[] data;
    }
};

void process(Vector v) {}

int main() {
    // process(10);  // 에러
    process(Vector(10));  // OK
}

예시 2: 변환 연산자

class Fraction {
    int numerator, denominator;
    
public:
    Fraction(int n, int d) : numerator(n), denominator(d) {}
    
    // ❌ 암시적 변환
    operator double() const {
        return (double)numerator / denominator;
    }
};

Fraction f(1, 2);
double d = f;  // 암시적 변환

// ✅ explicit 변환 연산자
class Fraction {
public:
    explicit operator double() const {
        return (double)numerator / denominator;
    }
};

// double d = f;  // 에러
double d = static_cast<double>(f);  // OK

변환 연산자 동작 흐름:

sequenceDiagram
    participant Code
    participant Compiler
    participant Op as Operator
    
    Code->>Compiler: double d = f;
    
    alt no explicit
        Compiler->>Op: implicit call
        Op->>Code: return double
        Note over Code: OK
    else with explicit
        Compiler->>Compiler: block implicit
        Compiler->>Code: compile error
    end
    
    Code->>Compiler: static_cast
    Compiler->>Op: explicit call
    Op->>Code: return double
    Note over Code: always OK

예시 3: bool 변환

class SmartPointer {
    int* ptr;
    
public:
    explicit SmartPointer(int* p) : ptr(p) {}
    
    // ✅ explicit bool
    explicit operator bool() const {
        return ptr != nullptr;
    }
};

int main() {
    SmartPointer sp(new int(10));
    
    if (sp) {  // OK: 조건문에서 허용
        std::cout << "유효" << std::endl;
    }
    
    // bool b = sp;  // 에러
    bool b = static_cast<bool>(sp);  // OK
}

explicit operator bool이 C++11에 들어오기 전에는, operator bool()을 그냥 두면 sp + 1이나 int n = sp;처럼 bool을 거쳐 정수로 변환되는 코드가 모두 컴파일되는 문제가 있었습니다. 그래서 멤버 함수 포인터로 변환하는 복잡한 “safe bool idiom”이 쓰였는데, C++11의 explicit 변환 연산자와 문맥적 bool 변환(contextual conversion) 규칙이 이를 대체했습니다. if, while, for의 조건, !, &&, ||, 삼항 연산자의 조건, static_assert와 noexcept의 인자에서는 explicit이어도 bool로 변환되고, 그 외의 곳에서는 변환되지 않습니다. 그래서 return sp;로 bool을 반환하는 함수는 에러가 나며 return static_cast<bool>(sp);나 return sp != nullptr;처럼 써야 합니다.

예시 4: 복사 생성자

class Widget {
public:
    Widget() = default;
    
    // explicit 복사 생성자 (드물음)
    explicit Widget(const Widget& other) {
        // ...
    }
};

Widget w1;
// Widget w2 = w1;  // 에러
Widget w2(w1);  // OK

explicit 복사 생성자가 “드물다”는 수준을 넘어 거의 쓰이지 않는 이유는, 값 전달과 값 반환이 모두 복사 초기화이기 때문입니다. void f(Widget w); f(w1);은 인자를 복사 초기화하므로 에러가 나고, Widget g() { Widget w; return w; }도 반환 시 이동이 불가능하면 복사 생성자가 필요해 에러가 납니다. 표준 컨테이너에 넣는 것도 막힙니다. 복사를 의도적으로 드러내고 싶다면 explicit 복사 생성자보다 clone() 같은 이름 있는 함수를 제공하는 편이 쓰기 쉽습니다.

C++11 explicit 확장

C++ 버전별 explicit 지원

기능C++98C++11C++20
생성자✅✅✅
변환 연산자❌✅✅
조건부 explicit❌❌✅ explicit(bool)
다중 인자 생성자❌✅ (중괄호 초기화)✅
// C++11: 변환 연산자에도 explicit
class MyClass {
public:
    explicit operator int() const {
        return 42;
    }
};

MyClass obj;
// int x = obj;  // 에러
int x = static_cast<int>(obj);  // OK

C++20 조건부 explicit

template<typename T>
class Optional {
public:
    // 조건부 explicit
    template<typename U>
    explicit(!std::is_convertible_v<U, T>)
    Optional(U&& value) : data(std::forward<U>(value)) {}
    
private:
    T data;
};

// int -> long은 변환 가능 → explicit 아님
Optional<long> opt1 = 10;  // OK

// string -> int는 변환 불가 → explicit
// Optional<int> opt2 = std::string("10");  // 에러

자주 발생하는 문제

문제 1: 의도하지 않은 변환

graph LR
    A[process 10 호출] --> B{String int 생성자}
    B -->|explicit 없음| C[int → String 암시적 변환]
    C --> D[임시 String 10 생성]
    D --> E[함수 실행]
    E --> F[⚠️ 버그 발생 가능]
    
    B -->|explicit 있음| G[❌ 컴파일 에러]
    G --> H[개발자가 의도 명확히 표현]
    H --> I[process String 10]
    I --> J[✅ 안전한 코드]
// ❌ explicit 없음
class String {
public:
    String(int size) {}
};

void process(String s) {}

process(10);  // 의도하지 않은 변환

// ✅ explicit
class String {
public:
    explicit String(int size) {}
};

실무 사례:

시나리오explicit 없을 때explicit 있을 때
process(10)String(10) 생성 후 전달컴파일 에러
String s = 10String(10) 생성컴파일 에러
String s(10)String(10) 생성String(10) 생성
return 10String(10) 반환컴파일 에러

문제 2: 복사 초기화

class Widget {
public:
    explicit Widget(int x) {}
};

// Widget w = 10;  // 에러
Widget w(10);  // OK
Widget w{10};  // OK (C++11)

문제 3: 함수 인자

void func(std::vector<int> vec) {}

// ❌ explicit 없으면
// func(10);  // int -> vector (의도하지 않음)

// std::vector 생성자는 explicit
func(std::vector<int>(10));  // OK

std::vector의 크기 생성자(vector(size_type n))가 explicit이라 func(10)은 컴파일되지 않지만, func({10})은 컴파일됩니다. 이때 선택되는 것은 크기 생성자가 아니라 initializer_list 생성자라서, 크기 10짜리가 아니라 원소 10 하나를 가진 벡터가 만들어집니다. std::vector<int> v{10};과 std::vector<int> v(10);의 결과가 다른 것과 같은 규칙으로, 중괄호 초기화에서는 initializer_list 생성자가 우선하기 때문입니다. explicit이 막아 주는 것은 “암시적 변환”뿐이고 중괄호가 부르는 생성자 선택까지 바꿔 주지는 않으므로, 크기를 의도한다면 괄호를 쓰는 습관이 필요합니다.

문제 4: bool 변환

class Pointer {
public:
    operator bool() const {  // explicit 없음
        return ptr != nullptr;
    }
    
private:
    int* ptr;
};

Pointer p;
int x = p;  // bool로 변환 후 int로 (의도하지 않음)

// ✅ explicit
explicit operator bool() const {}

bool 변환 문제 다이어그램:

graph TD
    A[Pointer p] --> B{operator bool explicit?}
    
    B -->|없음| C[int x = p]
    C --> D[Pointer → bool]
    D --> E[bool → int]
    E --> F[⚠️ x = 0 or 1]
    F --> G[의도하지 않은 정수 변환]
    
    B -->|있음| H[int x = p]
    H --> I[❌ 컴파일 에러]
    I --> J[if p 는 OK]
    I --> K[명시적 캐스트 필요]

허용되는 bool 변환 컨텍스트:

컨텍스트explicit bool설명
if (obj)✅ 허용조건문은 명시적 변환
while (obj)✅ 허용반복문 조건
obj && x✅ 허용논리 연산자
!obj✅ 허용논리 NOT
bool b = obj❌ 에러복사 초기화 금지
int x = obj❌ 에러정수 변환 금지

문제 5: 오버로드와 섞일 때

class Value {
public:
    Value(int x) { /* ... */ }     // explicit 없음
    Value(double x) { /* ... */ }  // explicit 없음
};

void process(Value v) { /* ... */ }

process(42);    // Value(int)
process(3.14);  // Value(double)
process('A');   // 컴파일됨: char → int 승격이 우선되어 Value(65)
process(42L);   // 에러: long → int와 long → double이 같은 순위라 모호함

암시적 변환 생성자가 여러 개 있으면 어떤 생성자가 불릴지를 오버로드 해석 규칙이 정합니다. 'A'는 char에서 int로의 승격(promotion)이 double로의 변환보다 순위가 높아 Value(int)가 선택되는데, 그 결과는 문자가 아니라 정수 65를 담은 객체입니다. long은 어느 쪽으로 가도 같은 순위의 변환이라 “call of overloaded … is ambiguous” 에러가 납니다. 생성자를 explicit으로 만들면 호출하는 쪽이 process(Value(42))처럼 직접 적어야 하므로 이런 순위 규칙을 외울 필요가 없어집니다.

암시적 변환에는 비용이 코드에 보이지 않는다는 문제도 있습니다. BigData(size_t n)이 원소 n개짜리 벡터를 할당하는 생성자라면, 루프 안의 process(1000000)은 숫자 하나를 넘기는 가벼운 호출처럼 읽히지만 매번 큰 벡터를 만들고 버립니다. explicit을 붙여도 process(BigData(1000000))의 비용은 같지만, 읽는 사람이 “여기서 객체를 만든다”는 사실을 알고 루프 밖으로 뺄지 판단할 수 있습니다.

사용 권장사항

explicit 사용 결정 플로우

graph TD
    A[생성자/변환 연산자 작성] --> B{단일 인자 생성자?}
    B -->|예| C{의도된 암시적 변환?}
    B -->|아니오| D{변환 연산자?}
    
    C -->|아니오| E[explicit 사용 ✅]
    C -->|예| F[explicit 생략 가능]
    
    D -->|예| G{bool 변환?}
    D -->|아니오| H[explicit 불필요]
    
    G -->|예| E
    G -->|아니오| C

코드 가이드라인

// ✅ explicit 사용 권장
// 1. 단일 인자 생성자
explicit MyClass(int x);

// 2. 변환 연산자
explicit operator int() const;

// 3. bool 변환 연산자 (필수)
explicit operator bool() const;

// ❌ explicit 불필요
// 1. 다중 인자 생성자
MyClass(int x, int y);  // 암시적 변환 안 됨

// 2. 복사/이동 생성자
MyClass(const MyClass&);  // explicit 드물음

// 3. 기본 생성자
MyClass();  // 인자 없음

실무 체크리스트

상황explicit 사용이유
Vector(size_t size)✅ 필수의도하지 않은 크기 변환 방지
String(const char*)❌ 생략 가능문자열 리터럴 변환은 자연스러움
operator bool()✅ 필수정수 변환 방지
operator int()✅ 권장의도하지 않은 산술 연산 방지
Widget(int, int)❌ 불필요다중 인자는 암시적 변환 안 됨
unique_ptr(T* ptr)✅ 필수포인터 자동 변환 방지

레거시 코드에 explicit을 도입할 때

기존 코드베이스에 explicit을 추가하는 작업은 clang-tidy 경고(google-explicit-constructor)만 없애면 되는 단순 작업처럼 보이지만, 실제로 해 보면 암시적 변환에 기대던 호출부가 곳곳에서 컴파일 에러로 드러납니다. 이 에러를 하나씩 고치는 과정이 곧 “이 호출은 정말 이 변환을 의도했나”를 검토하는 과정이 되고, 저는 이것이 explicit 도입의 실질적인 가치라고 봅니다.

# .clang-tidy
Checks: 'google-explicit-constructor'

가장 흔히 드러나는 것은 단위 혼동입니다. allocate(1024)는 바이트인지 킬로바이트인지 호출부만 봐서는 알 수 없고, 생성자가 암시적 변환을 허용하면 컴파일러도 아무 말을 하지 않습니다. 생성자를 explicit으로 바꾸면서 이름 있는 팩토리 함수를 함께 제공하면, 단위를 잘못 알고 있던 호출부가 수정 과정에서 자연스럽게 드러납니다.

class Size {
public:
    explicit Size(std::size_t bytes) : bytes_(bytes) {}

    static Size bytes(std::size_t n) { return Size(n); }
    static Size kilobytes(std::size_t n) { return Size(n * 1024); }
    static Size megabytes(std::size_t n) { return Size(n * 1024 * 1024); }

private:
    std::size_t bytes_;
};

void allocate(Size size);

// allocate(1024);                // 컴파일 에러
allocate(Size::kilobytes(1024));  // 의도가 코드에 드러남

에러가 한꺼번에 수백 개 쏟아지는 대형 코드베이스라면, 새로 작성하는 클래스부터 explicit을 강제하고 기존 클래스는 모듈 단위로 나눠 적용하는 편이 리뷰하기 쉽습니다. explicit은 컴파일 타임 검사일 뿐이라 바이너리와 런타임 성능에는 영향을 주지 않으므로, 되돌릴 부담은 호출부 수정뿐입니다.

실무 패턴

패턴 1: 스마트 포인터

template<typename T>
class UniquePtr {
    T* ptr_;
    
public:
    explicit UniquePtr(T* ptr = nullptr) : ptr_(ptr) {}
    
    ~UniquePtr() { delete ptr_; }
    
    explicit operator bool() const { return ptr_ != nullptr; }
    
    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
};

// 사용
UniquePtr<int> ptr(new int(42));
if (ptr) {  // OK: 조건문
    std::cout << *ptr << '\n';
}
// bool b = ptr;  // 에러: 복사 초기화

스마트 포인터의 원시 포인터 생성자가 explicit인 이유는 소유권 이전이 조용히 일어나는 것을 막기 위해서입니다. 만약 std::unique_ptr<int>가 int*에서 암시적으로 변환된다면, void take(std::unique_ptr<int> p); int x; take(&x);가 컴파일되어 함수가 끝날 때 스택 변수의 주소를 delete하는 크래시가 생깁니다. std::shared_ptr도 같은 이유로 explicit이라, std::shared_ptr<int> p = new int(1);은 에러이고 std::make_shared<int>(1)을 쓰도록 유도합니다. 이 예제의 UniquePtr는 복사 생성자를 막지 않아 복사하면 이중 해제가 나므로, 실제로는 복사를 = delete하고 이동만 허용해야 한다는 점도 덧붙여 둡니다.

패턴 2: 타입 안전 래퍼

class UserId {
    int id_;
    
public:
    explicit UserId(int id) : id_(id) {}
    
    int value() const { return id_; }
};

class OrderId {
    int id_;
    
public:
    explicit OrderId(int id) : id_(id) {}
    
    int value() const { return id_; }
};

void processUser(UserId uid) {}
void processOrder(OrderId oid) {}

int main() {
    UserId uid(123);
    OrderId oid(456);
    
    processUser(uid);  // OK
    // processUser(oid);  // 에러: 타입 불일치
    // processUser(123);  // 에러: explicit
}

패턴 3: 단위 타입

class Meters {
    double value_;
    
public:
    explicit Meters(double v) : value_(v) {}
    
    double value() const { return value_; }
};

class Kilometers {
    double value_;
    
public:
    explicit Kilometers(double v) : value_(v) {}
    
    explicit operator Meters() const {
        return Meters(value_ * 1000);
    }
};

void setDistance(Meters m) {}

int main() {
    Kilometers km(5.0);
    
    // setDistance(km);  // 에러: 암시적 변환 불가
    setDistance(static_cast<Meters>(km));  // OK: 명시적
    // setDistance(5.0);  // 에러: double → Meters 불가
}

단위 타입과 ID 타입에서 explicit이 가치를 발휘하는 것은 같은 기본 타입(int, double)을 쓰는 서로 다른 의미의 값이 섞이는 버그를 컴파일 타임에 막기 때문입니다. void transfer(int fromUser, int toUser, int amount) 같은 시그니처는 인자 순서를 바꿔도 컴파일되지만, UserId와 Money 타입으로 감싸면 순서가 틀리는 순간 에러가 납니다. 비용은 래퍼 선언과 .value() 호출이 늘어나는 정도이고, 멤버가 하나뿐인 트리비얼한 클래스라 최적화 빌드에서는 보통 기본 타입과 같은 코드가 생성됩니다. 다만 킬로미터를 미터로 바꾸는 explicit operator Meters()처럼 변환이 값을 바꾸는 경우에는 연산자보다 toMeters() 같은 이름 있는 함수가 의도를 더 잘 드러낸다는 의견도 많습니다.

FAQ

Q1: explicit은 언제 사용하나요?

A:

  • 단일 인자 생성자 (의도하지 않은 타입 변환 방지)
  • 변환 연산자 (특히 bool)
  • 타입 안전성이 중요한 래퍼 클래스

Q2: 성능 영향은?

A: 없습니다. explicit은 컴파일 타임 검사이므로 런타임 성능에 영향을 주지 않습니다.

Q3: 복사 생성자에도 explicit을 사용하나요?

A: 드뭅니다. 복사 생성자에 explicit을 사용하면 복사 초기화가 불가능해져 불편합니다. 특별한 이유가 있을 때만 사용합니다.

Q4: C++11에서 어떤 변화가 있었나요?

A: 변환 연산자에도 explicit을 사용할 수 있게 되었습니다.

explicit operator int() const;  // C++11

Q5: bool 변환 연산자는 항상 explicit인가요?

A: 권장됩니다. explicit operator bool()은 조건문에서는 사용 가능하지만, 정수로의 암묵적 변환을 막습니다.

Q6: C++20 조건부 explicit은 무엇인가요?

A: explicit(bool)로 컴파일 타임 조건에 따라 explicit 여부를 결정할 수 있습니다.

template<typename T, typename U>
explicit(!std::is_convertible_v<U, T>)
MyClass(U&& value);

Q7: 다중 인자 생성자는 explicit이 필요한가요?

A: 일반적으로 불필요합니다. 다중 인자 생성자는 암시적 변환이 발생하지 않습니다. 하지만 C++11 중괄호 초기화에서는 가능하므로 필요 시 사용합니다.

Q8: explicit 학습 리소스는?

A:

관련 글: 타입 변환, 복사 초기화, 스마트 포인터.

explicit은 생성자와 변환 연산자의 암시적 변환을 방지하여 타입 안전성을 높입니다.


같이 보면 좋은 글