프로그래밍 언어/Effective C++

[Effective C++]항목 29~31: 구현 2

우향우@ 2023. 4. 27. 19:17

항목 29 : 예외 안정성 

예외 안전성이란 어떤 함수가 자신이 실행 중 발생한 예외를 어떻게 처리하는지에 대한 안정성 수준을 이야기한다.

예외 안정성에서 신경써야하는 부분은 크게 두가지이다.

1. 동적할당, 뮤텍스등의 할당한 자원 해제

2. 로직 무결성 유지

1번째의 경우 스마트포인터, 소멸자 기반 뮤텍스등으로 쉽게 해결가능하다. 하지만 2번의 경우 스스로 로직을 잘짜야하기에 신경쓸 것이 많다.

우선 예외 안정성은 다음 3가지 수준을 가진다.

 

기본적인 보장

함수 실행 중 중간에 예외 발생시에도 무결성을 해치지않는다. 즉 정상적인 상태를 유지한다.

강력한 보장

함수 실행 중 중간에 예외 발생시 함수 호출전의 상태를 보존해준다. 즉 실행성공, 실행 실패 두가지 상태만 존재하며 원자성을 보장해준다.

예외불가 보장

함수가 밖으로 예외를 던지지않는다. 예외불가 보장 함수나 연산자만 사용하거나 내부에서 발생하는 예외를 스스로 완벽히 처리한다.

 

우선 예외안정성이 깨진 함수를 살펴보자.

int* exF()
{
	lock(&mutex);
	int* a = new int;
	*a = 5;
	unlock(&mutex);
	return a;
}

new int는 메모리 부족등의 예외가 발생할 수 있는 호출이다. 저기서 예외 발생시 mutex의 잠금은 해제되지않고 문제가 발생한다. lock 대신 소멸자에서 해제해주는 Lock 클래스를 사용하면 해결된다.

int* exF()
{
	Lock lock1(&mutex);
	int* a = new int;
	*a = 5;
	return a;
}

다음은 무결성이 깨진 함수를 살펴보자.

void exF(myStruct* ms)
{
	delete ms;
	ms = new myStruct(1,2,3);
}

기존 ms의 메모리를 해제하고 새 메모리를 할당해 ms에 달아주는 함수이다. 여기서 new MyStruct(1,2,3)에서 예외가 발생하면 ms는 삭제만되고 새 포인터를 가지지못한다. 즉 삭제된 포인터를 가르키고 있게 된다. 무결성이 깨졌다.

이는 다음과 같이 해결가능하다.

void exF(myStruct* ms)
{
	myStruct* temp = ms;
	ms = new myStruct(1,2,3);
	delete temp;
}

new에서 실패해도 ms의 자원이 해제되지않기에 문제가 발생하지 않는다.

 

 

함수를 만들 땐 예외불가 보장, 강력한 보장을 우선 도전해보자. 예외 불가 보장이 가장 좋겠지만 현실적으로 힘들기에 보통 강력한 보장을 많이 만든다. 하지만 이마저도 안된다면 기본적인 보장을 만들어 주자. 강력한 보장이 어려운 예시는 다음과 같다.

void exF()
{
	f1(); //외부 데이터 수정
	f2(); //외부 데이터 수정
}

f1이 성공하고 f2에서 예외발생 시 f2가 강력한 보장을 해주더라도 f1의 외부 수정은 이미 적용되었기에 이대로 예외가 나가면 exF는 강력한 보장이 되지않는다. 이를 막기 위해선 try catch로 감싸 외부 수정전 상태를 저장해뒀다가 복구해주어야한다. 하지만 이는 디자인적인 복잡함을 불러오며 f1,f2가 어떤 처리를 하느냐에 따라 심히 어려워질 수 있다. 만약 f1이 외부 데이터베이스를 수정하는 함수라면 사실상 복구가 불가능할 것이다. 이러한 경우에는 기본적인 보장이라도 지켜주어야 한다.

 

수정 후 맞바꾸기

클래스 멤버 함수에서 강력한 보장을 지켜주기 위한 한 디자인 방법으로 수정 후 맞바꾸기가 있다. 클래스는 자신의 멤버 변수들을 pImpl구조체에 정의하고 이에 대한 포인터를 가지는 식으로 설계한다. 그 후 자신의 멤버를 수정하는 함수를 만들 때 pImpl의 사본을 먼저 만들고 사본에 수정을 적용시킨 뒤 마지막에 예외에 안전한 swap으로 실제 멤버와 갈아끼워주면 된다.

 

항목 30 : 인라인 함수

인라인 함수는 함수 콜스택 호출 대신 호출코드 부분에 해당 함수 코드를 그대로 박아넣는것을 의미한다. 인라인은 콜스택이 없다는 점에서 성능상 유리하다. 하지만 코드가 긴 함수를 인라인으로 쓰면 코드의 총량이 너무 늘어나 오히려 성능이 저하될 수 있다. 코드량에 의한 메모리 사용량은 물론 그이 의한 페이징 증가 성능, 명령어 캐싱 성공률 감소에 의한 성능 저하등이 있을 수 있다. 하지만 반대로 코드가 적을 경우 이 성능저하가 적어지면 심지어 많이 적을 시 콜스택 호출을 위한 기계어보다 단순 함수 본문이 더 짧아 이 성능들 조차 증가할 수 있다. 간단히 말해 본문이 짧은 함수는 인라인 시 효율적이다.

inline 키워드

우리는 함수 앞에 inline이라는 키워드를 붙여 컴파일러에게 inline을 요청하며 유도할 수 있다. inline은 또한 함수의 중복 정의를 허용해주는 키워드이기도하다. inline을 위해서는 헤더에 함수 정의가 들어가야한다. inline에 대한 처리는 보통 컴파일시점에 일어나기 때문이다.(가끔 링킹시점에 일어나는 환경도 존재) 그렇기에 include한 소스파일에서 함수의 정의를 가지게 되며 컴파일시에 inline이 가능해지는 것이다. 따라서 inline키워드로 중복 정의를 허용한 뒤 헤더에 넣어줌으로써 컴파일이 해당 함수를 inline처리하도록 크게 유도할 수 있다. 하지만 어디까지나 유도이기에 컴파일러의 판단하에 inline을 하거나 하지않을 수 있다.

생성자, 소멸자

추가로 생성자등의 일부 함수는 우리가 직접 작성한 본문이 적더라도 기본적인 초기화 코드나 상위 계층 구조의 생성자를 호출하는 코드들이 보이지 않게 들어가기에 inline이 비효율적이다.

함수 포인터

또한 어떤 함수를 함수 포인터에 담는 행위를 하면 해당 함수는 인라인 처리가 되었든 안되었든 함수 본문이 실제로 생성된다. 함수 포인터에 담기 위해선 실체가 있어야하기 때문이다.

컴파일 의존성

인라인 함수의 한가지 단점은 컴파일 의존성을 늘리는 것이다. 만약 여러 소스 파일에서 한 헤더의 함수들을 include한다고 하자. 만약 inline없이 평범하게 함수를 선언했다면 해당 헤더의 cpp파일의 함수 본문이 바뀌어도 include한 소스 파일들에는 영향이 없기에 그들까지 재 컴파일 할 필요가 없다. 하지만 inline하였다면 헤더 파일에 본문이 들어가므로 헤더 파일의 수정이 일어나고 이로 인해 헤더파일을 include한 모든 소스 파일도 재 컴파일 해주어야한다.

 

항목 31 : 컴파일 의존성

빌드 과정에서 보통 컴파일이 대부분의 시간을 잡아먹고 링킹은 큰 시간이 들지않는다. 따라서 c++은 분할 컴파일을 통해 코드 파일등을 각각 컴파일하고 마지막에 링킹을 한다. 이를 통해 일부 코드 파일이 수정되면 수정된 파일만 재 컴파일하고 링킹만 다시하면 되는 것이다. 하지만 어떠한 코드 파일에서 다른 파일을 include하였다면 이야기가 달라진다. include당한 파일이 수정되면 이를 include한 모든 파일도 전처리 및 컴파일을 다시 해야한다. 이렇게 각 파일들 사이의 컴파일 의존성을 우리는 최대한 줄여야한다. 프로젝트의 크기가 커지면 컴파일의 시간이 정말 길어저 생산성에 영향을 주기 때문이다.

컴파일 의존성을 줄이는데 항상 사용되는 정신은 바로 선언과 정의를 때어놓는것이다.

기본중의 기본 함수 라이브러리

정말 기본적으로 함수 라이브러리를 생각해보자. 우리는 헤더엔 함수의 선언만을, 소스에 함수의 정의를 놓는다. 그리고 다른 파일들은 헤더파일을 include한다. 이러면 소스파일에서 함수 본문을 수정하더라도 헤더 및 이를 include한 파일들에 영향이 없다.

클래스(pImpl로 해결)

단순 함수는 쉬웠지만 클래스는 좀 더 생각할 거리가 있다. 클래스를 나타내는 헤더에는 해당 클래스의 선언과 멤버 함수들의 선언 그리고 멤버 변수들의 정의가 들어간다. 단순 명명을 떠나서 디자인적으로도 private에 해당하는 멤버 변수들은 인터페이스가 아닌 내부 구현 로직에 속하는 개념이므로 디자인 측면에서도 이들을 정의로 보는 것이 맞다. 하지만 해당 클래스를 include하여 정상적으로 사용하기 위해서는 멤버 함수 선언 및 멤버 변수 정의를 가진 헤더파일을 include하여야한다. 이 상태에서 클래스의 구현부 중 멤버 변수의 형태가 변한다면 이들을 include한 파일들을 모두 재 컴파일 해야할 것이다. 우리는 이를 pImpl 관용구 방식으로 해결한다. 이는 위 항목29에서 봤다시피 pImpl 구조체를 정의하고 이에 대한 포인터를 클래스가 가지도록 하는 것이다. 이는 이런식으로 구현된다.

1. 클래스의 멤버 변수만을 가진 구조체를 정의한 헤더를 만든다.

2. 클래스의 선언과 멤버 함수의 선언, 위 구조체에 대한 포인터 멤버 정의를 가진 클래스 헤더를 만든다.

3. 위 두 헤더를 include한 클래스 구현 소스 파일을 만들고 여기서 함수들을 정의한다. 구조체의 멤버들까지 접근할 수 있기에 완벽히 구현 가능하다.

4. 위 클래스 사용자 파일들은 클래스 헤더만 include하고 클래스의 인터페이스 함수들을 통해 해당 클래스를 사용한다.

이렇게 하면 해당 멤버 변수가 바뀌더라도 클래스 구현 소스 파일까지만 재컴파일이 일어나고 클래스 헤더와 사용자들은 재 컴파일이 필요없다.

클래스(추상 클래스로 해결)

클래스에 대한 컴파일 의존성 해결법은 상속으로도 해결 할 수 있다. 다음과 같이 구현한다.

1. 클래스에 대한 인터페이스 함수만을 가진 추상 클래스를 선언한 헤더를 만든다. 추가적으로 자손 객체 생성하고 자신의 포인터에 담아 리턴 하는 static함수에 대한 선언도 가진다.(리턴 값도 자신이고 선언뿐이기에 자손에 대한 include 필요없음,)

2. 위 추상 클래스를 상속받아 함수 선언 및 멤버 변수 정의를 한 클래스 헤더를 만든다.

3. 2번의 클래스를 정의한 cpp파일을 만든다.

4. 1,2번을 include하여 1번 클래스의 static함수를 정의하는 cpp파일을 만든다.

5. 사용자 파일에서 1번 클래스만 include하여 static함수로 객체를 생성하고 추상 클래스 포인터에 담아 가상 함수를 호출하여 사용한다.

이 역시 정의에 해당하는 2번 클래스가 수정되더라도 4번 cpp파일에만 영향이 간다.

클래스 전방선언

클래스의 내부 블럭없이 class abc;와 같은 식으로 전방선언 하는 것도 가능하다. 이렇게 클래스를 선언시 해당 클래스에 대한 포인터나 래퍼런스만 생성가능하다. 실제 객체는 생성하지 못하나 매개변수나 리턴값으로 선언하는 것은 가능하다. 만약 특정 헤더파일에서 다른 헤더파일의 클래스를 함수의 리턴값이나 매개변수로 선언하고 싶다면 클래스 블럭 선언 대신 전방선언을 가져올 수 있다. 선언에는 문제가 없기 때문이다. 이렇게 하면 해당 클래스의 멤버 변수등이 바뀌어도 해당 헤더가 영향이 없다. 이를 위해 제공자측 클래스에서는 전방선언에 대한 헤더파일도 따로 만들어 관리해주면 좋다.

 

 

 

 

 

 

참고서적

Effective C++