여러 스레드가 동기화 없이 const 멤버 함수를 호출하는 것은 읽기 연산을 하는 것이기 때문에 안전한 일이다.

하지만 mutable에 의해 값의 수정이 가능해지는 경우에는 더이상 스레드에 안전해지지 않는다.

이런 경우 atomic 또는 뮤텍스로 동기화를 해주어야 한다.

 

동기화가 필요한 변수 하나 또는 메모리 장소 하나에 대해서는 atomic이 적합하지만 둘 이상의 변수나 메모리 장소를 하나의 단위로서 조작해야 하는 경우에는 뮤텍스를 사용하는 것이 바람직하다.

 

또한 const 멤버 함수가 언제라도 동시적 실행 상황에 처할 것이라고 가정하는 것이 안전하다.

 

◾ 동시적 문맥에서 쓰이지 않을 것이 확실한 경우가 아니라면, const 멤버 함수는 스레드에 안전하게 작성하라.

◾ std::atomic 변수는 뮤텍스에 비해 성능상의 이점이 있지만, 하나의 변수 또는 메모리 장소를 다룰 때에만 적합하다.

개념적으로 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)의 왼값 버전과 오른값 버전을 다른 방식으로 처리할 수 있다.

+ Recent posts