현대적 C++ 기능을 잘 쓸 수 있도록 관련 내용을 정리한다.
- 목록
1. iterator보다 const_iterator를 선호한다.
2. 예외를 방출하지 않을 함수는 noexcept로 선언한다.
3. 가능하면 항상 constexpr을 사용한다.
4. const 멤버 함수를 스레드에 안전하게 작성한다.
5. 특수 멤버 함수들의 자동 작성 조건을 숙지한다.
1. iterator보다 const_iterator를 선호한다.
원소를 바꿀 필요가 없는 반복자를 사용하는 함수에 const_iterator를 사용하도록 한다.
cbegin()과 cend()는 const_iterator를 반환한다.
std::vector<int> values;
// ...
auto iter = std::find(values.cbegin(), values.cend(), 2000);
values.insert(iter, 2021);
C++11에서 begin(Container)과 end(Container)는 비멤버 함수로써 호출 가능하다.
C++14에서 cbegin(...)/cend(...)/rbegin(...)/rend(...)/crbegin(...)/crend(...)가 추가되었다.
template<typename C, typename V>
void findAndInsert(C& container, conts V& targetVal, const V& insertVal)
{
using std::cbegin;
using std::cend;
auto it = std::find(cbegin(container), cend(container), targetVal);
container.insert(it, insertVal);
}
C++11버전의 std::cbegin()의 구현
실제 container의 타입에 따라 반환 형식이 결정된다.
template<class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
return std::begin(container);
}
2. 예외를 방출하지 않을 함수는 noexcept로 선언한다.
예외를 방출하지 않는 함수 인터페이스 설계
int f(int x) throw(); // f는 예외를 방출하지 않음: C++98 방식
int f(int x) noexcept; // f는 예외를 방출하지 않음: C++11 방식
실행시점에서 어떤 예외가 f 함수 밖으로 튀어나오면 f의 예외 명세가 위반된다.
C++98에서는 예외 명세가 위반되면 호출 스택이 f를 호출한 지점에 도달할 때까지 풀리며(unwind), 그 지점에서 몇 가지 동작이 취해진 후 프로그램 실행이 종료된다.
C++11에서는 프로그램 실행이 종료되기 전에 호출 스택이 풀릴 수도 있고 풀리지 않을 수도 있다.
noexcpet 함수에서 컴파일러의 최적화기는 예외가 함수 바깥으로 전파될 수 있다고 해도 실행시점 스택을 풀기 가능 상태로 유지할 필요가 없다. 또한 예외가 noexcept 함수를 벗어난다 해도 noexcept 함수 안의 객체들을 반드시 생성의 반대 순서로 파괴해야 하는 것도 아니다.
C++98에서 throw()는 그런 최적화 유연성이 없다.
반환형식 함수이름(매개변수목록) noexcept; // 최적화 여지가 가장 크다.
반환형식 함수이름(매개변수목록) throw(); // 최적화 여지가 작다.
반환형식 함수이름(매개변수목록); // 최적화 여지가 가장 작다
std::vector와 같은 컨테이너는 이동 연산이 예외를 방출하지 않음이 확실할 때만 이동 연산을 이용한다.
push_back() 함수에서 사이즈 초과시 사이즈를 자동으로 조절하게 되는 데 이 때 이동 연산을 사용할 수 있다. 다만 이동 연산이 예외를 방출하지 않음이 확실할 때만, 이동 연산을 사용한다. 일반적인 원소 삽입도 마찬가지이다.
반드시 정확성(correctness)을 고려하여 noexcept를 사용한다.
최적화 여지가 있다하더라도 대부분 함수가 예외에 중립적(exception-neutral)이기 때문에 스스로 예외를 던지지 않더라도 다른 함수가 예외를 던지면 그 예외를 상위 호출로 그대로 통과시킨다. 그런 예외 중립적 함수는 noexcept가 될 수 없다.
3. 가능하면 항상 constexpr를 사용하라
constexpr은 어떠한 값이 상수일 뿐만아니라 컴파일 시점에서 알려지는 것을 의미한다.
배열 사이즈와 같이 컴파일 시점에 알 수 있는 값을 constexpr 변수로 설정 가능하다.
constexpr auto arrSize = 100;
std::array<int, arrSize> arr;
※ constexpr를 반환하는 함수
컴파일 시점 상수를 요구하는 문맥에 constexpr 함수를 사용할 수있다. 함수의 매개변수들이 모두 컴파일 시점에서 알 수 있다면, 함수 결과는 컴파일 도중에 계산된다.
만약 매개변수 중 하나라도 컴파일 시점에서 알 수 없다면, 함수 결과는 실행 시점에서 계산된다.
지수 함수를 계산하는 함수 pow(int base, int exp)
constexpr int pow(int base, int exp) noexcept
{
//...
}
constexpr auto num = 5;
std::array<int, pow(3, num)> array; // 실행 시점에 배열 사이즈가 결정된다.
int read_num;
FILE* pFile;
fopen_s(&pFile, "data.txt", "rb");
fread(%read_num, 4, 1);
fclose(pFile);
std::array<int, pow(3, read_num)> array; // 실행 시점에 결정되므로 컴파일 에러
C++11과 C++14에서 constexpr 함수의 차이점
C++11에서는 constexpr 함수의 내용이 한 줄로 구현되어야 했지만 C++14에서는 그런 제약이 없다.
또한 C++11에서는 void를 제외한 반드시 리터럴 형식(int, float, double, 클래스)을 반환해야한다. 생성자도 constexpr로 선언 가능하다.
class Point{
public:
constexpr Point(double newX, double newY) noexcept;
constexpr double xVal() const noexcept;
constexpr double yVal() const noexcept;
private:
double x;
double y;
};
C++11에서 void Set(int x); 함수는 constexpr 함수의 const성과 void가 리터럴 형식이 아니기 때문에 constexpr로 선언할 수 없었다. C++14에서는 이러한 제약이 없어졌다.
class Point{
public:
// ...
constexpr void SetX(double newX) noexcept { x = newX; }
constexpr void SetY(double newY) noexcept { y = newY; }
};
constexpr를 객체나 함수의 인터페이스이자 비constexpr보다 더 넓은 문맥에서 사용가능 하다.
5. const 멤버 함수를 스레드에 안전하게 작성한다.
다항식의 근을 풀어주는 다항식(Polynomial) 클래스 - polynomial solver
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
if (!rootsAreValid)
{
rootsAreValid = true;
//... 근 계산
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
roots() 함수는 근이 계산된 캐시가 없을 경우에만 계산을 하고 그 결과를 캐시로 저장한다. 이에 따른 캐시값과 계산이되었었는지 판별하기 위한 변수들은 수정되어야 하지만 const 함수내에 사용하기 위해 mutable로 지정하고 roots() 함수내에서 수정될 수 있음을 의미한다.
만약 두 스레드가 roots() 함수를 동시에 호출한다고 했을 때,
Polynomial p;
스레드 1 | 스레드 2 |
auto rootsOfP = p.roots(); | auto valsGivingZero = p.roots(); |
외부상으로 roots() 함수는 const 즉 읽기 함수이기 때문에 데이터를 바꿀 일이 없어보인다. 하지만 이 예에서는 thread-safe하지는 않다. 실제로는 mutable 객체들이 변경될 수 있기 때문이다. 이러한 클라이언트 코드는 서로 다른 스레드에서 같은 메모리를 동기화없이 읽고 쓰려하기 때문에 race condition을 만족하게 되고 미정의 행동을 유발할 수 있다.
ex) 스레드 1이 rootsAreValid를 true로 바꾸고 컨텍스트 스위칭 후 스레드 2가 rootsAreValid=true를 보고 계산되지 않은 이상한 값을 반환한다.
위는 이진 세마포어 mutex를 사용하여 동기화 문제를 해결할 수 있다.
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const
{
std::lock_guard<std::mutex> g(mtx); // 락 가드로 뮤텍스를 잠근다.
if (!rootsAreValid) // 캐시 없음
{
rootsAreValid = true;
//... 근 계산
}
return rootVals; // 계산된 근을 반환
}
private:
mutable std::mutex mtx;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};
mutex 객체를 잠그고 푸는 함수는 비const이지만 const함수 내에 사용하기 위해 mutable로 지정한다.
mutex 객체는 복사, 이동이 불가능하기 때문에 Polynomial 클래스의 복사, 이동 함수는 삭제된다.
std::atomic
만약 읽고 쓰는 공유 변수 수가 하나라면 std::atomic 변수로 선언하여 해당 변수에 대한 변경이 원자적 연산으로 일어나게 한다. (중간에 다른 스레드에 의해 선점되지 않는다.)
하지만 std::atomic 변수의 남용을 지양해야하는데, 다음 예를 보면 그 이유를 알 수 있다.
class Widget
{
public:
// ...
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
똑같이 스레드 1과 스레드 2가 위 magicValue()를 읽고 쓰는 상황이 일어난다 하자.
한 스레드가 magicValue()를 호출하고 비싼 계산들을 수행한 후 cachedValue에 그 값을 대입했다. cahedValid=true로 바꾸기 전 선점한 다른 스레드가 비싼 계산들을 다시 한번 계산하게 된다.
cachedValid를 앞의 순서에 배치해도 문제는 여전히 발생한다.
class Widget
{
public:
// ...
int magicValue() const
{
if (cacheValid) return cachedValue;
else {
cacheValid = true;
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};
스레드 1이 cachedValid를 true 설정후 스레드 2는 계산되지도 않은 cachedValue를 반환하게 된다.
동기화가 필요한 변수가 하나 또는 메모리 장소 하나에 대해서는 std::atomic을 사용하는 것이 적합하지만, 둘 이상의 변수나 메모리 장소를 함수와 같이 하나의 단위로 같이 조작해야할 때는 뮤텍스를 사용하는 것이 바람직하다.
그러나 실행 함수가 다중 스레드 환경을 가정하고 있지 않다면 동기화 문제를 고려할 필요가 없다.
6. 특수 멤버 함수들의 자동 작성 조건을 숙지한다.
특수 멤버 함수(special member function)은 C++가 자동으로 작성하는 멤버 함수들을 가리킨다.
그런 함수는 "기본 생성자", "소멸자", "복사 생성자", "복사 대입 연산자"에 해당한다.
C++11은 이 그룹에 "이동 생성자(move constructor)", "이동 대입 연산자(move assignment operator)"를 추가했다.
class Widget{
public:
//...
Widget(Widget&& rhs); // 이동 생성자
Widget& operator=(Widget&& rhs); // 이동 대입 연산자
};
이동 연산은 각 비정적 자료에 대해 멤버별 이동을 수행한다.
실제로는 각 멤버별 이동에 대해 기본적으로 복사 연산을 std::move(멤버)를 통해 이동으로 대체할 것으로 요구하는 것이다. 만약 그 멤버가 이동 대입 연산에 대해 정의가 되어 있다면 멤버는 이동 대입이 되고 그렇지 않다면 복사가 된다.
복사 생성자와 복사 대입 연산자
C++98/C++11에서는 복사 생성자를 사용자가 직접 선언했더라 하더라도 복사 대입 연산자를 자동으로 작성해준다. 그반대로 복사 대입 연산자를 선언했다고 해도 복사 생성자는 자동 작성된다.
이동 생성자와 이동 대입 연산자
두 이동 연산은 독립적이지 않다. 둘 중 하나를 선언하면 컴파일러는 다른 하나를 작성하지 않는다. 그 논리는 "사용자가 이동 연산에 대해 기본 기능이 적합하지 않아 직접 정의했다"고 컴파일러는 생각한다.
마찬가지로 복사 생성자/ 복사 대입 연산자 둘 중 하나라도 직접 선언했다면 이동 관련 특수 멤버 함수들은 자동 작성되지 않는다. 비슷한 논리로 "사용자가 복사 연산을 직접 정의했으니 자동 작성한 이동도 못 믿을만 하다"라는 근거가 된다.
그 반대도 마찬가지이다. 이동 생성자/ 이동 대입연산자 둘 중 하나라도 직접 선언했다면 복사 관련 특수 멤버함수들은 자동 작성되지 않는다.
- 3의 법칙 (Rule of Three)
만일 복사 생성자와 복사 대입 연산자, 소멸자 중 하나라도 선언했다면 나머지 둘 도 선언해야한다는 관례와 같은 규칙이 있다. C++에서 자원 관리를 사용자에게 맡기는데 이 자원 관리에 관여하는 3대 멤버 함수 중 하나를 기본 작성되는 함수를 부적합하다고 생각하여 직접 선언했다면 다른 것도 직접 사용자가 정의할 의무가 있는 것이다.
따라서 3대 멤버 함수가 하나라도 선언되어 있다면, 이동 관련 특수 멤버 함수들은 작성되지 않는다.
이동 연산들에 대한 자동 작성 조건
- 어떤 복사도 선언되어 있지 않다.
- 어떤 이동도 선언되어 있지 않다.
- 소멸자가 선언되어 있지 않다.
만약 사용자가 특수 멤버 함수들을 명시적으로 기본 작성값을 사용하고 싶다면 default를 이용하여 명시할 수 있다. 이는 위 조건에 의해 자동 작성되지 않는 함수들을 기본 작성값으로 사용해도 무방할 때 자동 생성으로 함수를 지원한다.
class Base{
public:
virtual ~Base() = default; // 가상 소멸자
// 복사 관련 특수 멤버
Base(const Base&) = default;
Base& operator=(const Base&) = default;
// 이동 관련 특수 멤버
Base(Base&&) = default;
Base& operator=(const Base&&) = default;
};
※ 특수 멤버 함수 작성 조건 정리
기본 생성자: 사용자 선언이 없을 때.
소멸자: 사용자 선언이 없을 때. 기본적 소멸자는 noexcept 인터페이스를 포함한다. 기반 함수가 가상이라면 가상함수로 정의.
복사 생성자: 사용자 선언이 없을 때. 이동 연산이 없을 때.
복사 대입 연산자: 사용자 선언이 없을 때. 이동 연산이 없을 때.
이동 생성자/ 이동 대입 연산자: 사용자 선언이 없을 때. 소멸자, 복사 생성자, 복사 대입연산자가 없을 때.
함수 템플릿의 경우 무시된다.
함수 템플릿이 복사 연산과 같은 모습을 하고 있지만 컴파일러는 여전히 기본 복사와 이동 연산에 대한 정의를 자동 작성해준다.
class Widget{
template<typename T>
Widget(const T& rhs);
template<typename T>
Widget& operator=(const T& rhs);
};
참고 : Effective Modern C++
'Advanced C++' 카테고리의 다른 글
[C++] 형 변환 (2) dynamic_cast / static_cast / reinterpret_cast / const_cast (0) | 2021.04.01 |
---|---|
[C++] 형 변환 type casting (0) | 2021.04.01 |
[Modern C++] (3-1) 현대적 C++에 적응하기 (0) | 2021.03.25 |
[C++ 클래스] 템플릿 클래스와 전방 선언 (0) | 2021.03.24 |
[Modern C++] (2) auto 키워드 (0) | 2021.03.22 |