개념적으로 constexpr은 값이 상수임을 알릴 뿐만 아니라 컴파일 타임에 알려진다는 점을 나타낸다.

 

객체에 constexpr이 적용되면 상수화되고 해당 값은 컴파일 타임에 알려진다. 하지만 const는 그럴거라는 보장이 없다.

모든 constexpr 객체는 const이지만 모든 const 객체는 constexpr인것은 아니다.

 

다만 이 개념이 함수에 적용되면 약간 다르게 적용된다.

constexpr 함수는 컴파일 타임의 상수를 인수로 호출한 경우에는 컴파일 타임에 상수를 산출하지만 런타임에 산출된 값으로 호출하면 런타임에 값을 산출한다.

 

constexpr 함수에 대한 올바른 관점

◾ 컴파일 타임 상수를 요구하는 문맥에 constexpr 함수를 사용할 수 있다. 인수의 값이 컴파일 타임에 알려진다면 결과는 컴파일 타임에 계산되고 컴파일 타임에 알려지지 않는다면 코드의 컴파일이 거부된다.

◾ 컴파일 타임에 알려지지 않는 하나 이상의 값들로 constexpr 함수를 호출하면 보통의 함수처럼 작동한다. 별도의 함수로 나눌 필요 없이 하나의 constexpr  함수를 두 가지 용도로 사용하면 된다.

 

constexpr int pow(int base, int exp) noexcept {
    ...
}

constexpr auto cnumConds = 5;
std::array<int, pow(3, numConds)> results;

함수에 constexpr이 붙었다고 해서 결과값이 반드시 const인 것은 아니다.

 

constexpr int pow(int base, int exp) noexcept { // C++11
    return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

C++11에서 constexpr 함수는 실행 가능 문장이 많아야 하나만 있어야 한다. 보통은 return문이 될 것이고 삼항연산자와 재귀를 이용해서 함수의 표현력을 확장시킬 수 있다.

 

constexpr int pow(int base, int exp) noexcept { // C++14
    auto reuslt = 1;
    for (int i = 0; i < exp; ++i) result *= base;
    
    return result;
}

C++14는 제약이 상당히 느슨해져서 실행 가능 문장이 여러줄이어도 괜찮다. 다만 반드시 리터럴 타입들을 받고 돌려주어야 한다.

 

 

class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept : x(xVal), y(yVal) {}
    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }
};
...
constexpr Point p1(9.4, 27.7); // OK
constexpr Point p2(28.8, 5.3); // OK

생성자를 constexpr로 선언할 수 있는 이유는 주어진 인수들이 컴파일 타임에 알려진다면 생성된 객체의 멤버 변수들 역시 컴파일 타임에 알려질 수 있기 때문이다.

컴파일 타임에 알려진 값으로 초기화된 객체는 getter역시 constexpr이 될 수 있다. 연쇄적으로 다 가능해진다.

 

class Point {
public:
    ...
    constexpr void setX(double newX) noexcept { x = newX; } // C++14
    constexpr void setY(double newY) noexcept { y = newY; }
};

만약 setter를 constexpr로 구현한다면 C++11에서는 구현할 수 없다. constexpr 멤버 함수는 암묵적으로 const로 선언되고 void타입은 리터럴이 아니기 때문이다.

 

연산들을 컴파일 타임으로 옮길수록 실행시간은 빨라지지만 컴파일 시간이 길어지게 된다.

 

constexpr역시 가능하면 항상 사용하는것이 좋다.

덧붙여서 constexpr 함수에서는 입출력 문장들이 허용되지 않는다.

 

◾ constexpr 객체는 const이며, 컴파일 도중에 알려지는 값들로 초기화된다.

◾ constexpr 함수는 그 값이 컴파일 도중에 알려지는 인수들로 호출하는 경우에는 컴파일 타임 결과를 산출한다.

◾ constexpr 객체나 함수는 비constexpr 객체나 함수보다 광범위한 문맥에서 사용할 수 있다.

◾ constexpr은 객체나 함수의 인터페이스의 일부이다.

함수의 예외 방출 행동에 관해서 의미있는 정보는 함수가 예외를 하나라도 던지는지, 아니면 절대로 던지지 않는지에 대한 이분법적 정보뿐이다.

 

함수의 예외 방출 행동은 매우 중요한 사항이다. 함수의 호출자는 호출하려는 함수의 noexcept 여부를 조회할 수 있고 그 조회 결과에 따라 호출 코드의 예외 안정성이나 효율성에 영향을 미친다. const 한정자 만큼이나 중요하다.

 

int f(int x) throw(); // C++98 스타일
int f(int x) noexcept; // C++11 스타일

예외를 만들지 않는 함수에 noexcept를 적용하는 것은 인터페이스 설계상의 문제뿐만 아니라 실제로 컴파일러가 더 나은 목적 코드를 만들어낼 수 있다.

만약 예외를 던지지 않겠다고 명시했음에도 예외가 발생했을 때, throw는 호출 스택이 해당 함수를 호출한 지점에 도달할 때까지 풀리고 noexcept는 호출 스택이 풀릴 수도, 아닐 수도 있기 때문에 컴파일러가 조금 더 최적화를 할 수 있게 된다.

 

 

noexcept가 큰 의미를 갖는 다른 예를 한번 들어보자.

벡터에 push_back을 통해 새 요소를 집어넣을 때 용량을 초과하게 된다면 새로운 메모리를 할당받아서 크기를 확장시키고 기존에 있던 요소들을 복사가 아닌 이동시키는 것이 최적화 측면에서 더 낫다. 그런데 만약 이동 도중에 예외가 발생한다면?

이미 이동이 진행중이라 기존의 벡터가 수정된 상태이고 다시 원래대로 되돌린다고 해도 또 한번 예외가 발생할 수도 있다.

그래서 이동 연산이 예외를 방출하지 않음이 확실하지 않은 경우는 이동 대신 복사로 대체되어 수행될 수 있다.

 

실제로 벡터의 push_back등 표준 라이브러리의 함수들은 가능하면 이동하되 필요하면 복사를 수행한다.

이동 연산이 예외를 방출하지 않는다는 것이 확실한 경우에만 이동 연산을 수행한다.

함수가 이를 알아내는 방법이 바로 noexcept이다. 주어진 연산이 noexcept로 선언되어 있는지 여부를 점검하게 된다.

 

참고로 벡터의 push_back에서 이동 연산과 복사 연산을 선택할 때 사용되는 함수는 move_if_noexcept이다. 예외 방출 여부에 따라 왼값 또는 오른값을 골라서 반환시켜준다.

 

swap역시 noexcept가 바람직하게 적용되는 경우이다.

 

 

최적화와 관련된 좋은점에 대해서만 언급했지만 최적화보다는 정확성이 더 중요하다. noexcept는 함수의 인터페이스 일부이기 때문에 확실한 경우에만 noexcept로 선언해야 한다. 만약 나중에 제거하게 되는경우 클라이언트 코드가 깨질 가능성이 존재한다.

 

대부분의 함수는 예외에 중립적(다른 함수의 예외 통과)이라서 noexcept가 될 수 없다.

함수를 noexcept로 만들기 위해 구조를 작위적으로 비틀기보다는 자연스러운 noexcept 구현이 되는 것이 중요하다.

 

◾ noexcept는 함수의 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.

◾ noexcept 함수는 비noexcept 함수보다 최적화의 여지가 크다.

◾ noexcept는 이동 연산들과 swap, 메모리 해제 함수들, 그리고 소멸자들에 특히나 유용하다.

◾ 대부분의 함수는 noexcept가 아니라 예외에 중립적이다.

Effective C++에서 다루었던 내용이 반복자에서도 마찬가지로 적용된다.

반복자가 가리키는 것을 수정할 필요가 없다면 const_iterator를 사용해주면 된다.

C++98때와는 다르게 이제는 컨테이너에서 cbegin, cend로 const_iterator를 매우 쉽게 얻어낼 수 있다.

 

const_iterator를 사용하지 못하는 경우는 한 가지, 최대한 일반적인 라이브러리를 작성할 때이다.

begin, end함수를 비멤버 함수로 제공해야 할 때, C++11에서는 begin, end 딱 두가지만 추가되었기때문에 비멤버 const_iterator를 작성하더라도 C++11 환경에서는 동작하지 않는다.

물론 이런 경우라면 const 참조 매개변수를 통해 비멤버 begin, end를 호출하면 된다.

 

template<class C>
auto cbegin(const C& container)->decltype(std::begin(container)) {
    return std::begin(container); // 멤버 함수 begin 또는 cbegin 호출
}

C++11같이 비멤버 const_iterator가 지원되지 않는다면 그냥 begin을 반환할 것이고 지원된다면 cbegin을 반환할 것이다.

이 템플릿은 내장 배열 타입일때도 사용이 가능해진다. 반복자 begin 대신 배열의 첫 원소를 가리키는 포인터가 반환된다.

 

설명이 조금 길어졌지만 요점은 반복자 역시 가능하면 const_iterator를 사용하라는 것이다.

 

◾ iterator보다 const_iterator를 선호하라.

◾ 최대한 일반적인 코드에서는 begin, end, rbegin 등의 비멤버 버전들을 해당 멤버 함수들보다 선호하라.

파생 클래스의 가상 함수 구현은 대체로 기반 클래스의 가상 함수 부분을 재정의 하게 된다.

 

파생 클래스에서 함수의 재정의가 일어나려면 다음과 같은 필수 조건들을 만족해야 한다.

 

◾ 기반 클래스의 함수가 반드시 가상 함수이어야 한다.

◾ 기반 함수의 파생 함수의 이름이 반드시 동일해야 한다.(소멸자 제외)

◾ 기반 함수의 파생 함수의 매개변수 타입들이 반드시 동일해야 한다.

◾ 기반 함수와 파생 함수의 상수성(const)이 반드시 동일해야 한다.

◾ 기반 함수와 파생 함수의 반환 타입과 예외 명세가 반드시 호환되어야 한다.

 

위의 제약들은 C++98에도 있던것들이고 C++11에서 참조 한정사들이 동일해야 한다는 제약이 하나 더 추가됐다.

참조 한정사는 멤버 함수를 왼값 또는 오른값에만 사용할 수 있도록 제약시키는 것이다.

 

class Widget {
public:
    void doWork() &; // *this가 왼값일때만 호출
    void doWork() &&; // *this가 오른값일때만 호출
    ...
};

기반 함수가 상수성을 가지고 있다면 파생 함수에서도 상수성을 가져야하는 것처럼 참조 한정사 역시 동일하게 유지시켜주어야 한다. 만약 참조 한정사가 서로 다르다면 재정의가 일어나지 않는다.

 

class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;
};

class Derived: public Base {
public:
    virtual void mf1(); // 상수성 유지 x
    virtual void mf2(unsigned int x); // 매개변수 타입이 다름
    virtual oid mf3() &&; // 참조 한정사가 다름
    void mf4() const; // 가상 함수가 아님
};

위의 4가지 경우 모두 재정의가 이루어지지 않은 경우들이다. 무서운점은 컴파일러가 경고를 띄워주지 않는다는 점이다. 명백한 실수를 저질렀음에도 모르고 넘어갈 수 있는 것이다.

 

그래서 파생 클래스에서 함수를 재정의 할 때, 기반 클래스의 함수를 재정의 한다고 명시하기 위해 override 식별자를 사용한다.

 

class Derived: public Base {
public:
    virtual void mf1() override;
    virtual void mf2(unsigned int x);
    virtual void mf3() && override;
    virtual void mf4() override;
};

이제 이 코드는 컴파일되지 않는다. 재정의 명시를 했지만 재정의가 이루어진게 하나도 없기 때문이다.

 

마지막으로 참조 한정사가 붙은 함수가 오버로딩 되었다면 오버로딩된 모든 함수들에도 참조 한정사를 지정해주어야 한다. 그렇지 않으면 중의적인 호출이 되기 때문에 의도하지 않은 호출이 이뤄질 수 있다.

 

◾ 재정의 함수는 override로 선언하라.

◾ 멤버 함수 참조 한정사를 이용하면 멤버 함수가 호출되는 객체(*this)의 왼값 버전과 오른값 버전을 다른 방식으로 처리할 수 있다.

다른 개발자에게 코드를 제공할 때 특정 함수를 호출하지 못하게 하려면 해당 함수를 선언하지 않으면 된다. 그런데 C++이 자동으로 생성하는 멤버 함수들은 그럴수가 없다. 기본 생성자나 복사 생성자 같은것들 말이다.

함수의 사용을 막는 두 가지 방법에 대해서 알아보자.

 

방법 1 : private 접근 지정자와 함수의 정의를 없애기

C++98에서는 자동으로 생성하는 멤버 함수의 접근 지정자를 private으로 명시적 선언을 하되 정의를 하지 않아서 사용하지 못하게 하는 테크닉을 이용했다.

 

template<class charT, class traits = char_traits<charT>>
class basic_ios : public ios_base {
public:
    ...
private:
    basic_ios(const basic_ios&); // 정의X
    basic_ios& operator=(const basic_ios&); // 정의X
};

복사를 막기위해 private 접근 지정과 더불어서 복사 연산자와 복사 대입 연산자의 선언만 했기 때문에 호출 시도시 컴파일 에러가 발생하게 된다. 혹시라도 friend나 멤버 함수를 통한 접근을 시도하더라도 정의가 되어있지 않기 때문에 링크 에러가 발생한다.

이런 방법도 나쁘지 않지만 C++11은 좀더 명확한 목적을 가진 방법이 추가되었다.

 

방법 2 : 함수를 사용하지 않는다고 선언하기

template <class charT, class traits = char_traits<charT>>
class basic_ios : public ios_base {
public:
    ...
    basic_ios(const basic_ios&) = delete;
    basic_ios& operator=(const basic_ios&) = delete;
};

위처럼 아예 해당 함수를 사용하지 않는다고 선언하는 것이다.

방법 1과 결과적으로 크게 달라보이지 않아서 그저 취향 문제로 보일수도 있지만 매우 큰 차이가 하나 있다.

방법 1은 멤버 함수나 friend에서 혹시라도 접근하게 되면 링크 단계까지 가서야 문제가 파악되지만 방법 2는 컴파일 단계에서 바로 파악된다.

또한 삭제된 함수는 public으로 선언하는것이 관례이다. 컴파일러별로 함수의 삭제보다 접근 지정자의 문제를 먼저 걸고 넘어지는 경우가 있기 때문에 에러 메시지를 보고 사용자가 오해할 소지가 있기 때문이다.

 

또하나의 차이가 있다. 방법 1은 접근 지정자를 사용하기 때문에 오직 멤버 함수에만 적용이 가능하지만 방법 2는 어떤 함수도 삭제할 수 있다는 것이다.

 

bool isLucky(int number);

if (isLucky('a')) ... // 정수만 받아야 하는데..?
if (isLucky(true)) ...
if (isLucky(3.5)) ...

어떤 함수가 반드시 정수만 받아야 한다면 어떻게 해야할까?

방법 1이라면 해결 방법이 없다. 멤버 함수가 아니기 때문에 접근 지정자가 사용이 불가능하기 때문이다.

하지만 방법 2라면 타입별로 오버로딩하고 해당 함수들을 삭제처리 하면 된다.

 

bool isLucky(int number);
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete; // float, double 둘 다 배제

삭제된 함수들은 비록 사용할수는 없어도 어쨌든 코드에 존재하는 프로그램의 일부이기 때문에 함수 오버로딩의 선택지에 포함이 된다. 하지만 사용하지 않는다고 선언했기 때문에 컴파일 에러가 발생하게 된다.

 

 

템플릿 특수화와 조합하면 원치 않은 템플릿의 인스턴스화도 막을 수 있다.

 

template<typename T>
void processPointer(T* ptr);

template<>
void processPointer<void>(void*) = delete;
// const void에 대해서도 처리해야 한다

template<>
void processPointer<char>(char*) = delete;
// const char에 대해서도 처리해야 한다

좀더 확실하게 하려면 const volatile등 관련된 모든 타입에 대한 특수화도 삭제해야 할 것이다.

 

 

클래스 내 함수 템플릿의 일부를 인스턴스화 시키지 않기 위해 부분 특수화를 적용하는 경우가 발생한다면 방법 1로는 해결할 수 없다는 단점도 있다.

 

class Widget {
public:
    template<typename T>
    void processPointer(T* ptr);
private:
    template<> // 오류 발생
    void processPointer<void>(void*);
};

이게 왜 안되냐면 템플릿 특수화는 클래스 범위가 아니라 이름 공간의 범위에서 작성해야 하기 때문이다.

 

class Widget {
public:
    template<typename T>
    void processPointer(T* ptr);
};

template<>
void Widget::processPointer<void>(void*) = delete;

방법 2는 문제 없이 삭제할 수 있다. 방법 2가 방법 1에 비해 모든 면에서 예외없이 상위호환이기 때문에 함수를 삭제할거라면 정의되지 않은 비공개 함수대신 delete를 사용하면 된다.

 

◾ 정의되지 않은 비공개 함수보다 삭제된 함수를 선호하라.

◾ 비멤버 함수와 템플릿 인스턴스를 비롯한 그 어떤 함수도 삭제할 수 있다.

+ Recent posts