본문 바로가기

study/C++

[C++][Effective C++] 26~31. 구현

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 26~31 항목으로 아래와 같다.

  • 26. 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자
  • 27. 캐스팅은 절약, 또 절약! 잊지 말자
  • 28. 내부에서 사용하는 객체에 대한 "핸들"을 반환하는 코드는 되도록 피하자
  • 29. 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
  • 30. 인라인 함수는 미주알고주알 따져서 이해해 두자
  • 31. 파일 사이의 컴파일 의존성을 최대로 줄이자

요약

26. 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자

프로그램 제어 흐름이 변수의 정의에 닿으면 생성자가 호출된다. 그리고 그 변수가 유효 범위를 벗어나면 소멸자가 호출된다. 이런 비용을 낭비하지 않기 위해서 사용하지 않을 변수는 미리 정의해놓지 말아야 한다.

지금 당장은 아니어도 나중에 쓸 변수라도 미리 선언하지 않는 것이 좋다. 기껏 선언해두었는데 중간에 유효 범위를 벗어나는 예외상황이나 반환이 발생하면 어떡할 것인가? 꼭 필요해지기 전까지는 변수 정의를 미루는 편이 낫다.

 

또, 변수를 정의할 때 초기화 인자를 주는 것도 중요하다. 정의 후 복사 대입하는 비용보다 정의 당시에 초기화시키는 비용이 훨씬 저렴하다.

 

이렇게 변수를 사용하기 전까지 변수의 정의를 늦추는 것은 기본이고 초기화 인자를 손에 넣기 전까지 정의를 늦출 수 있어야 한다는 것이 이번 항목의 메시지다.

 

반복문에서는 어떨까? 반복문 안에서 사용하는 변수는 반복문 밖에서 정의하고 반복문 안에서 대입해야 할까? 반복분 안에서 정의해야 할까? 전자의 방법과 후자의 방법을 정리하면 다음과 같다.

  • 반복문 밖에서 정의, 반복문 안에서 대입 : 생성자 1번 + 소멸자 1번 + 대입 n번
  • 반복문 안에서 정의 : 생성자 n번 + 소멸자 n번

 

이는 대입 연산 비용이 생성자 + 소멸자 연산의 비용보다 저렴할 경우에는 전자의 방법이 유용하다. 반면, 그 외의 경우에는 반복문 안에서 정의하는 것이 저렴하다.

다른 관점에서 보면, 전자의 방법은 반복문 안에서만 사용할 변수의 유효 범위가 더 넓어지게 된다. 따라서, 프로그램의 이해도나 유지보수성이 역으로 안 좋아질 수도 있다.

이 두 점을 고려해서 반복문에서 사용할 변수의 정의 위치는 결정해야 한다.

 

27. 캐스팅은 절약, 또 절약! 잊지 말자

C++은 "어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다"는 동작 규칙을 바탕으로 하고있다. 이론적으로 컴파일만 깔끔하게 되면, 그 이후엔 어떤 객체에 대해서도 불안전한 연산이나 말이 안 되는 연산을 수행하려 하지 않는다.

그런데, C++은 이 타입 시스템의 제약을 가볍게 벗어날 수 있는 "캐스트(cast)"라는 것이 있다. 캐스트는 온갖 문제를 발생시키는 문제이자 C++을 늙게 만든 원흉이다. 그만큼 C++은 캐스팅을 조심히 써야 한다.

 

C++ 캐스팅은 아래와 같다.

  • const_cast<T> (표현식)
    • 객체의 상수성을 없애는 용도
  • dynamic_cast<T> (표현식)
    • 안전한 다운캐스팅(safe downcasting)을 위한 용도
    • 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰이기도 한다.
    • 런타임 비용이 높은 캐스트 연산자다.
  • reinterpret_cast<T> (표현식)
    • 포인터를 int로 바꾸는 등 하부 수준 캐스팅을 위한 용도
    • 하부 수준 코드 외에 사용될 일이 거의 없어야 한다.
  • static_cast<T> (표현식)
    • 암시적 변환을 강제로 진행하는 용도
    • int를 double로 바꾸는 등의 변환을 말한다.
    • 타입 변환을 거꾸로 수행하는 용도다. (void *를 일반 타입의 포인터로 바꾸거나 기본 클래스의 포인터를 파생 클래스의 포인터로 바꿈)

 

C 스타일의 캐스팅이 아니라 C++ 스타일의 캐스팅을 사용하는 이유는 아래와 같다.

  • 형 변환 여부를 알아보고 찾아보기 쉽다.
  • 캐스트를 사용한 목적을 보다 명확하게 지정하기 때문에 컴파일러에서 에러를 진단할 수 있다.

캐스팅은 단순히 형 변환을 컴파일러에게 알려준다고 생각하면 안 된다. 아래 코드를 보면, 파생 클래스 객체에 대한 기본 클래스 포인터를 만드는 코드다. 이때, 파생 클래스의 포인터의 offset을 적용해 실제 기본 클래스의 포인터 값을 구하는 동작이 런타임에 이루어진다. 즉, 객체 하나에 대한 두 포인터 값(주소)이 한 개가 아니라 그 이상이 될 수 있다는 것이다.

class CBase {};

class CDerived : public CBase {};

int main(void) {
	CDerived derived;
	CBase *pBase = &derived; // CBase *로 가리킬 때의 주소와 CDerived *의 주소가 다름
}

 

위와 같은 다중 상속 구조에서는 항상 문제가 생길 수 있지만, 단일 상속일 때도 발생할 수 있는 경우도 있다. 따라서 C++을 쓸 때는 데이터가 어떤 식으로 메모리에 있을 것이라, 섣부른 가정을 피해야 하며 가정 기반의 캐스팅은 문제만 발생한다는 것이다. 객체의 메모리 배치 구조를 결정하는 방법과 객체의 주소를 계산하는 방법은 컴파일러마다 천차만별이다. 따라서, 어떤 플랫폼에서 메모리 배치를 꿰고 있어 캐스팅에 문제없을 것이라 예상하더라도 다른 플랫폼에서는 통하지 않을 수 있다는 것이다.

 

캐스팅이 들어가면, 실제로는 맞는 것 같아 보여도 실제로는 틀린 코드를 쓰면서도 모르는 경우가 많아진다. 가상 함수를 파생 클래스에서 재정의해서 구현할 때 기본 클래스 버전의 가상 함수를 먼저 호출하도록 하는 코드를 보자. 아래의 코드는 맞는 것 같아 보이지만 실제로는 틀린 코드다. (구형 캐스팅을 써도 마찬가지다.)

// 기본 클래스
class CWindow {
public:
	// 기본 클래스의 onResize 구현
	virtual void onResize() {}
};

// 파생 클래스
class CSpecialWindow : public CWindow {
public:
	// 파생 클래스의 onResize 구현
	virtual void onResize() {
		// 기본 클래스로 캐스팅하고, 그것에 대한 onResize를 호출하도록 함 (Error. 동작 안 함)
		static_cast<CWindow>(*this).onResize();
	}
};

 

위의 캐스팅(static_cast <CWindow>(*this)) 과정에서 *this의 기본 클래스 부분에 대한 사본이 임시적으로 만들어진다. 그리고 그 사본의 onResize() 함수가 호출되고 있는 것이다. 즉, 현재 객체의 상위 클래스의 onResize가 아닌, 임시 객체에서 호출된 것이다. 사본 객체에서 호출된 함수에서 멤버의 값을 바꾸기라도 하려 한다면, 사본의 멤버 값이 변경되는 불상사가 발생한다.

이 문제를 풀기 위해서는 캐스팅을 빼야 한다. 그리고, 그냥 현재 객체에 대고 기본 클래스 버전의 onResize() 함수를 호출하도록 만들면 된다.

class CSpecialWindow : public CWindow {
public:
	virtual void onResize() {
		CWindow::onResize(); // *this에서 CWindow의 onResize()를 호출
	}
};

 

정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않는다. 캐스팅은 꼭 필요한 경우인가에 대한 의문이 항상 남는다. 즉, 최대한 격리시키라는 것이다. 혹시 캐스팅이 필요하다면, 캐스팅을 해야 하는 코드는 내부 함수 속에 몰아넣고 그 안에서 발생하는 일들은 함수를 호출하는 외부에서는 도저히 알 수 없도록 막아두도록 하자.

 

28. 내부에서 사용하는 객체에 대한 "핸들"을 반환하는 코드는 되도록 피하자

"핸들"이 무엇인지 먼저 알고 가도록 하자. 핸들은 다른 객체에 손을 댈 수 있게 하는 매개체로, 참조자 / 포인터 / 반복자가 여기에 해당된다. 이번 항목에서는 핸들을 반환할 것을 피하자고 얘기한다.

 

사각형을 사용하는 프로그램을 만들어보자. 

class CPoint {
public:
	CPoint(int x, int y);

	void setX(int newX);
	void setY(int newY);
};

class CRectData {
public:
	CPoint m_upperLeft;		// 좌측 상단 포인트
	CPoint m_lowerRight;	// 우측 하단 포인트
};

class CRectangle {
public:
	CRectangle(CPoint &pointUpperLeft, CPoint &pointLowerRight);
    
	// 엥?
	CPoint &getUpperLeft() const {
		return pData->m_upperLeft;
	}

	// 엥?
	CPoint &getLowerRight() const {
		return pData->m_lowerRight;
	}

private:
	CRectData *pData;
};

 

위의 코드에서 getUpperLeft() 메서드와 getLowerRight() 메서드를 보자. 두 함수는 "상수 멤버 함수"다. 즉, 함수 안에서 멤버 값을 변경할 수 없다. 그런데, 반환 값을 보면 핸들을 반환하고 있다. private 멤버가 갖는 내부 정보를 외부에서 제어할 수 있게 핸들을 반환하고 있다. 아래와 같이 외부에서는 상수 멤버 함수를 호출 후, 내부의 값을 변경할 수 있다는 것이다. 심지어 아래의 코드에서는 상수 객체의 내부 값을 변경하고 있는 것이다.

	CPoint point1(0, 0);
	CPoint point2(100, 100);
	const CRectangle rectangle(point1, point2);

	rectangle.getUpperLeft().setX(10); // 외부에서 내부 멤버 값을 변경할 수 있다.

 

여기까지만 해도 두 가지를 알 수 있다.

  1. 클래스의 데이터 멤버는 아무리 숨겨도 해당 멤버의 참조자를 반환하는 함수가 있다면, 그 함수의 접근도로 캡슐화 정도가 결정된다.
    • 즉, 데이터 멤버를 private 접근 제한하더라도, 해당 멤버의 참조자를 반환하는 함수가 public이라면, 캡슐화 정도는 약해지며 public의 접근 제한을 갖는 것과 같다.
  2. 어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 밖에 저장되어 있다면(포인터를 반환한다면), 호출부에서 데이터(포인터가 가리키는 곳의 실제 데이터)의 수정이 가능하다. (비트 수준의 상수성 참조)

 

위의 코드에서 내부 데이터를 변경하는 문제는 아래와 같이 코드를 변경하면 방지할 수 있다.

class CRectangle {
public:
	CRectangle(CPoint &pointUpperLeft, CPoint &pointLowerRight);
    
    // const 참조자 반환
	const CPoint &getUpperLeft() const {
		return pData->m_upperLeft;
	}
    
	// const 참조자 반환
	const CPoint &getLowerRight() const {
		return pData->m_lowerRight;
	}

private:
	CRectData *pData;
};

 

이제는 해당 함수 호출부에서 참조자를 반환받더라도 상수 참조자이기 때문에 값을 변경할 수 없다. 값을 읽을 수는 있지만 쓸 수는 없는 것이다. 이런 것을 의도적인 캡슐화 완화로 볼 수 있다.

 

그럼에도 아직 핸들을 반환했을 때 문제가 생기는 문제가 남아있다. 가장 큰 문제는 "무효 참조 핸들(dangling handle)"이다. 핸들이 있기는 하지만, 핸들을 따라갔을 때 실제 객체 데이터가 없다는 것이다.

이는 반환받은 핸들을 임시적으로 사용했을 때 쉽게 발생할 수 있다. 임시 객체가 만들어지고, 이 임시 객체를 대상으로 멤버 및 내부 데이터의 핸들까지 얻었다고 해보자. 하지만 결국 임시 객체는 소멸되며, 외부에서 받은 핸들은 남아있지만 실제 핸들이 가리키던 데이터는 이미 사라진 상태가 된다. 즉, 주소 값만 외부에 남긴 채 사라졌다는 것이다.

 

핸들 반환을 무조건 하지 말라는 것은 아니다. 제목에서 알 수 있듯이 "피하자"는 것이다. 예외적으로 operator[] 연산자가 있다. 실제 클래스 개개의 원소를 참조할 수 있도록 해주고 있는데, 이는 예외적인 경우다. 일반적으로는 "피하자".

 

29. 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

예외 안전성(exception safety)을 확보하는 작업은 어려운 일이다. 예외 안전성을 갖는 함수는 예외가 발생했을 때 아래와 같이 동작해야 한다.

  • 자원 누출 문제 : 자원이 새지 않아야 한다.
  • 자료구조 오염 문제 : 자료구조가 더럽혀지지 않아야 한다.

위의 조건을 만족하지 않는 코드부터 보도록 하자.

class CPrettyMenu {
public:
	// 배경그림을 바꾸는 메서드
	void changeBackground(std::istream &isImgSrc) {
		lock(&m_mutex);

		delete m_background;					//< 이전 배경그림 삭제
		++m_imageChanges;						//< 배경 그림 횟수 갱신
		m_background = new CImage(isImgSrc);	//< 새로운 배경그림 할당

		unlock(&m_mutex);
	}

private:
	Mutex m_mutex;			// 뮤텍스
	CImage *m_background;	// 현재 배경그림
	int m_imageChanges;		// 배경그림 변경 횟수
};

 

위의 코드는 예외 안전성의 두 조건에 대해 만족하지 않는다.

  • new CImage(isImgSrc) 표현식에서 예외를 던질 경우, unlock() 함수가 실행되지 않아 뮤텍스가 잡힌 채로 남게 된다.
  • new CImage(isImgSrc)가 예외를 던지면 m_background 변수가 가리키는 객체는 이미 삭제된 후다. 게다가 새 그림이 제대로 깔린 게 아닌데도 m_imageChanges 변수는 이미 증가되어 있다.

 

첫 번째로, 자원 누출 문제는 쉽게 잡을 수 있다. 항목 13과 14에서의 아이디어를 통해 뮤텍스를 적절하게 해제하는 CLock 클래스를 따라 하면 바로 해결된다.

void CPrettyMeny::changeBackground(std::istream &isImgSrc) {
    CLock lock(&m_mutex);					//< 뮤텍스 락 획득 후, 필요 없어질 시점에 바로 해제하게 됨

    delete m_background;					//< 이전 배경그림 삭제
    ++m_imageChanges;						//< 배경 그림 횟수 갱신
    m_background = new CImage(isImgSrc);	//< 새로운 배경그림 할당
}

 

두 번째로, 자료구조 오염 문제를 해결해야 한다. 그전에 용어 공부를 먼저 하자. 예외 안전성을 갖춘 함수는 아래의 세 개의 보장(guarantee) 중 하나를 제공한다.

  1. 기본적인 보장(basic guarantee)
    • 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것을 유효한 상태로 유지한다.
    • 어떤 객체나 자료구조도 더럽혀지지 않으며 모든 객체의 상태는 내부적 일관성을 유지한다. (모든 클래스 불변속성 만족)
    • 프로그램의 상태가 정확히 어떤 상황인지 예측이 안 될 수 있다. (배경화면 변경 중에 예외 발생 시 이전 배경그림을 유지하고 있는지, 기본 배경그림을 갖는지 등 사용자의 예측이 불가)
  2. 강력한 보장(strong guarantee)
    • 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않는다.
    • 원자적(atomic)인 동작으로, 호출이 실패하면 함수 호출이 없었던 것처럼 상태를 유지한다.
    • 편의성보다는 강력한 보장을 제공하는 것으로, 기본적인 보장을 사용하는 것보다 사용이 쉽다. (예측 가능한 결과가 성공 또는 실패로, 변경 또는 유지이기 때문)
  3. 예외 불가 보장(nothrow guarantee)
    • 예외를 절대로 던지지 않는다는 보장 한다.
    • 약속한 동작은 언제나 끝까지 완수한다. (기본 제공 타입은 모든 연산에서 예외를 던지지 않는다.)

 

예외 불가 보장은 현실적으로는 어려워 대부분 기본적인 보장 또는 강력한 보장 사이에서 고르게 된다. 먼저, 강력한 보장을 적용해보도록 하자. C++11부터 지원하는 shared_ptr을 사용하긴 하지만, 좀 더 명확하게 개념을 이해하기 위해 자원관리용으로 사용한다.

class CPrettyMenu {
public:
	// 배경그림을 바꾸는 메서드
	void changeBackground(std::istream &isImgSrc) {
		CLock lock(&m_mutex);

		// m_background 내부 포인터를 새엇ㅇ한 객체의 결과로 바꾼다.
		m_background.reset(new CImage(isImgSrc));
		++m_imageChanges;
	}

private:
	Mutex m_mutex;
	std::shared_ptr<CImage> m_background; 	// 자원관리를 위해 shared_ptr 사용
	int m_imageChanges;
};

 

스마트 포인터를 사용했기 때문에 이전의 배경그림(m_background)을 개발자가 직접 삭제하지 않아도 된다. 생성과 동시에 인자로 입력해, 새로운 배경 그림이 제대로 만들어질 때만 이전 배경그림의 삭제 작업이 이루어질 수도 있다. 게다가 코드까지 간단해졌다.

이렇게, changeBackground() 메서드 안에서는 강력한 보장을 적용했다.

isImgSrc 스트림의 읽기 오프셋이 이동해있을 수는 있어, 따지자면 changeBackground() 메서드는 현재 기본적인 보장을 하는 것이다. 매개변수를 istream을 쓰지 말고, 배경그림 파일 이름을 나타내는 타입으로 바꾸면 해결할 수 있을 것이다.

 

이번에는 예외 안전성의 강력한 보장을 제공하는 일반적인 설계 전략을 살펴보도록 하자. 이 전략은 "복사 후 맞바꾸기(copy-and-swap)"이라는 이름으로 알려져 있다. 객체를 수정할 때, 함수 안에서 객체의 사본을 만들고 사본을 수정하는 것이다. 그리고, 사본에 대한 수정이 모두 완료되면 사본과 원본을 swap 하는 전략이다. 이 swap의 과정은 예외를 던지지 않는 연산 내부에서 수행될 때 강력한 보장을 제공할 수 있다.

 

'pimpl 관용구'를 사용(항목 31 참조)하는 것인데, 원본 객체의 모든 데이터를 별도의 구현(implementation) 객체에 넣어두고, 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 하는 식으로 구현한다.

// 실제 객체의 데이터 부분을 구현 클래스가 모두 갖고 있음
struct PMImpl { // PMImpl은 CPrettyMenu의 private 멤버이기 때문에 구조체로 간단히 만들은 것. 클래스로 만들고 getter를 만들어도 됨
	std::shared_ptr<CImage> m_background;
	int m_imageChanges;
};

class CPrettyMenu {
public:
	// 배경그림을 바꾸는 메서드
	void changeBackground(std::istream &isImgSrc) {
		using std::swap; // 메서드 내 swap이 std::swap도 찾을 수도 있게 해놓음

		CLock lock(&m_mutex);

		// 복사 생성자로 사본 객체를 만듦
		std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));

		// 사본에 대한 수정
		pNew->m_background.reset(new CImage(isImgSrc));
		++(pNew->m_imageChanges);

		// 사본과 원본의 맞바꾸기
		swap(pImpl, pNew);
	}

private:
	Mutex m_mutex;
	std::shared_ptr<PMImpl> pImpl; // 실제 객체의 데이터를 갖는 구현 객체를 갖고 있음
};

 

일반적으로 어떤 함수에서 강력한 예외 안전성을 갖도록 보장하지는 않는다. 어떤 함수 안에서 호출되는 다른 함수들도 모두 강력한 예외 안전성을 만족해야 하기 때문이다. 그리고 함수 안에서 호출되는 각각의 다른 함수들을 수행하고 나왔을 때 무엇이든 상태가 변경되어 있을 것인데, 바깥 함수에서 강력한 예외 안전성을 제공하기에는 쉽지 않다.

따라서, 강력한 예외 안전성 보장을 위해 열심히 하더라도 이런 문제들을 마주치기 쉽다는 것은 알고 있어야 한다. 효율 문제도 무시할 수 없다. 복사 후 맞바꾸기 기법은 사본을 생성하는 공간과 시간을 감수해야 한다. 강력한 예외 안전성 보장을 할 수 없다면 기본적인 보장 쪽으로 선회할 수밖에 없다. 반드시 실용성이 확보될 때만 강력한 예외 안전성 보장을 하도록 하자.

 

그리고, 예외 안전성을 반만 갖춘 시스템이라는 것은 없다. 하나의 함수라도 예외 안전성이 없다면 그 시스템은 전체가 예외에 안전하지 않은 시스템인 것이다. 자원이 노출되고 자료구조가 더럽혀지기 때문이다.

 

앞으로는 새로운 함수를 만들거나 기존의 코드를 고칠 때 예외에 안전한 코드를 만들 방법을 진지하게 고민해야 한다. 자원 관리가 필요할 때 자원 관리용 객체(항목 13 참조)를 사용하는 것부터가 시작이다. 자원 누출만큼은 확실히 막아줄 수 있다. 그러고 나서 예외 안전성에 대한 보장을 고민해야 한다. 실용적으로 제공할 수 있는 보장을 결정해야 한다. 예외 안전성 보장을 결정한다면, 반드시 문서로 남겨서 인수인계자가 평가할 수 있도록 해야 한다. 예외 안전성 보장은 함수의 인터페이스에서 외부에 노출되는 중요한 부분이기 때문에 신중하게 결정해야 한다.

 

30. 인라인 함수는 미주알고주알 따져서 이해해 두자

인라인 함수함수처럼 동작하는 데다가 메크로보다 훨씬 안전해 쓰기 좋다. 함수 호출에서 발생하는 오버헤드도 없다. 게다가 컴파일러 최적화는 함수 호출이 없는 코드가 연속적으로 이어진 구간에 적용되기 때문에, 인라인 함수를 사용하면 함수 본문에 문맥별(context-specific) 최적화를 건다. 실제 대부분의 컴파일러는 아웃라인 함수(일반 함수, 인라인 함수의 반대의 느낌을 주기 위해 사용)의 호출 시에는 이런 최적화를 적용하지 않는다.

 

그러나, 인라인 함수는 함수 호출 문을 그 함수의 본문으로 바꿔치기하는 것이라, 목적 코드(object code)의 크기가 커질게 뻔하다. 인라인 함수로 인해 엄청나게 부풀려진 코드는 성능의 걸림돌이 되기 쉽다. 페이징 횟수가 늘어나고 명령의 캐시 적중률이 떨어질 것이다. 그로 인해 수행 성능이 떨어진다는 것이다.

물론, 반대로 본문 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 호출문보다 짧아지는 코드로 대체될 수도 있다. 이 경우에는 목적 코드의 크기도 작아지는 효과를 볼 수 있다.

 

inline은 컴파일러에 대해 "요청"하는 것이지 "명령"하는 것이 아니다. 요청은 암시적/명시적 방법으로 나뉜다. 먼저 암시적 방법을 보도록 하자.

class CPerson {
public:
	int age() const { return theAge; }

private:
	int theAge;
};

 

위의 코드에서 age() 메서드는 분명 멤버 함수다. 하지만, 이런 경우에는 암시적으로 인라인 함수로 선언된다.

 

아래는 명시적인 방법을 사용한 것이다. 예로 표준 라이브러리의 max 템플릿의 구현 형태다. 명시적으로 "inline"을 써둔 것을 알 수 있다.

template<typename T>
inline const T &std::max(const T &a, const T &b) {
	return (a < b) ? b : a;
}

 

위 함수를 보고 인라인과 템플릿에 대한 오해가 있을 수 있다. 먼저, 인라인 함수는 대체적으로 헤더 파일에 있어야 하는 게 맞다. 왜냐하면 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문이다. 본문으로 바꿔치기하려면, 일단 컴파일러가 그 함수의 형태를 알고 있어야 한다.

템플릿도 대체적으로 헤더에 들어가 있는 게 맞다. 템플릿을 인스턴스로 만들려면 어떻게 생겼는지 컴파일러가 알아야 하기 때문이다. 그런데, 템플릿 인스턴스화는 인라인과 별개의 과정이다. 템플릿과 인라인을 묶어서 다뤄야 한다는 생각은 버리도록 하자. 템플릿이 굳이 인라인이 될 이유가 없다면, 명시적이든 암시적이든 변환하지 않으면 된다. 어쨌든 인라인도 비용이 필요한 과정이기 때문이다.

 

인라인은 컴파일러에서 무시할 수 있는 요청이라는 점으로 돌아와서, 대부분의 컴파일러는 인라인 함수로 선언되어 있어도 복잡한 함수(재귀 또는 루프)는 절대로 인라인 확장을 하지 않는다.

또한, 이 인라인 함수에 대한 주소를 취하는 코드가 있다면, 인라인 확장을 모두 하지 않는다. 인라인 함수에 대한 함수 포인터로 인라인 함수를 호출한다면, 결국에는 아웃라인 함수를 호출하는 것인데, 아웃라인 함수 본문을 만드는 수밖에 없다.

 

즉, 인라인 함수가 실제로 인라인이 될 지에 대한 여부는 개발자가 사용하는 빌드 환경(컴파일러)에 달렸다. 다행히 대부분의 컴파일러는 요청 함수에 대한 인라인이 실패했을 때의 경고 메시지를 준다. (항목 53 참조)

 

인라인 함수는 사실 직접 사용하지 않아도, 많이 사용되고 있다. 예를 들어 파생 클래스의 생성자를 생각해보자. 파생 클래스의 생성자를 별도로 정의하지 않고 호출하면, 기본 클래스의 생성자가 호출되고 기본 클래스의 멤버가 초기화된다. 그리고 파생 클래스가 소멸될 때는 역으로 소멸되기도 한다. 아무런 코드를 작성하지 않아도 말이다. 이는 컴파일러가 파생 클래스의 생성자 등에 그렇게 동작하는 임의의 코드를 넣어준다고 생각하면 된다. C++가 '무엇을' 해야 할지 정해두었고, 컴파일러마다 '어떻게' 해야 할지를 각자 결정해두고, 결정한 대로 코드를 채워주고 있다.

 

라이브러리 제작자는 인라인으로 함수를 선언하는 것에 신중해야 한다. 인라인으로 어떤 함수를 정의하고, 배포했다고 해보자. 사용자가 해당 인라인 함수를 사용해서 어떤 응용 프로그램을 만들었다. 그런데, 라이브러리 제작자가 해당 인라인 함수의 내용을 변경하면, 기존의 해당 라이브러리를 사용해 빌드한 응용 프로그램은 변경된 내용을 따르지 못한다. 즉, 라이브러리 차원에서 바이너리 업그레이드를 제공할 수 없다.

 

그럼 무엇은 인라인 함수로 만들어야 할까? 우선, 아무것도 인라인으로 선언하지 말자. 꼭 인라인 해야 하는 함수 또는 정말 단순한 함수부터 인라인 함수로 선언하자. 디버깅을 잘할 수 있도록 만들고, 정말 필요한 위치에 인라인 함수를 두자. 수동 최적화인 셈이다.

 

31. 파일 사이의 컴파일 의존성을 최대로 줄이자

컴파일 의존성이 생기는 문제의 핵심은 C++가 인터페이스와 구현을 깔끔하게 분리하는 일을 못하기 때문이다. C++ 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 많이 지정하고 있다. 바로 클래스 멤버에 있는 객체다.

class CPerson {
public:
	CPerson(const std::string &sName, const CDate &date, const CAddress &addr);
    
private:
	std::string m_name;		// 구현 세부 사항
	CDate m_date;			// 구현 세부 사항
	CAddress m_addr;		// 구현 세부 사항
};

 

위의 코드만으로는 CPerson 클래스가 컴파일 될 수 없다. CPerson의 구현 세부사항인 string, CDate, CAddress가 어떻게 정의되었는지 모르기 때문이다. 이들에 대한 정의를 얻어와야 할 때 쓰는 것이 #include 지시자다.

바로 이 #include 지시자가 컴파일 의존성을 만드는 것이다. 여기서는 CPerson을 정의한 파일과 포함한 헤더 파일들 사이의 컴파일 의존성을 만든다. 포함한 헤더 파일 중 하나라도 바뀌거나, 헤더 파일마다 엮인 다른 파일이 바뀌면 CPerson 클래스는 다시금 컴파일되어야 한다. 그리고, 이 CPerson을 정의한 파일을 사용한 파일들도 마찬가지다.

 

그렇다고 필요한 정의를 모두 하나의 파일에 몰아넣는 것은 문제가 있다. 첫 번째로, string은 클래스가 아니라는 것이다. typedef로 정의한 것이다. 그러니 string은 반드시 헤더로 불러와야 한다. 두 번째로, 컴파일러가 컴파일 도중 객체의 크기를 모두 알아야 하는 것이다. 멤버로 들어있는 포인터가 아닌 객체의 크기는 가변적일 텐데 무슨 수로 구할 수 있겠는가.

이럴 때는 'pimpl 관용구'를 사용하는 것이 일반적이다. 이렇게 설계하면 대상 클래스의 구현 세부 사항과는 거리를 둘 수 있다. 구현 클래스 부분은 마음대로 고쳐도 되며, 사용자 쪽에서는 컴파일을 다시 할 필요가 없다. 사용자가 구현 부분과 직접 연결되지 않기 때문이다. 그야말로 인터페이스와 구현이 분리되는 것이다. 이렇게 인터페이스와 구현을 나누는 것은 '정의부에 대한 의존성(dependencies on definitions)'을 '선언부에 대한 의존성(dependencies on declarations)'으로 바꾸는 것이 열쇠다. 이게 바로 컴파일 의존성을 최소화하는 핵심 원리다.

 

헤더 파일을 만들 때는 실용적인 의미를 갖는 한 자체조달(self-sufficient) 형태로 만들어야 한다. 정 안 되면 다른 파일에 대해 의존성을 갖도록 하되, 정의부가 아닌 선언부에 대한 의존성을 갖도록 하는 것이다. 정리를 해보도록 하자.

  • 객체 참조자 및 포인터로 충분하다면 객체를 직접 쓰지 않는다.
    • 어떤 타입에 대한 참조자 및 포인터를 정의할 때, 그 타입의 선언부만 필요하다.
    • 어떤 타입의 객체를 정의할 때, 그 타입의 정의까지 준비되어야 한다.
  • 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존한다.
    • 어떤 클래스를 사용하는 함수를 선언할 때, 그 클래스의 정의를 갖지 않아도 된다. (그 클래스 객체를 값으로 전달하거나 반환하더라도)
    • 해당하는 함수가 호출된다면, 호출되기 전에는 정의가 파악되어야 한다. 그런데도 당장에 정의가 필요하지 않은 이유는 "모든 사람이 호출하는 것은 아니"기 때문이다. "아무도 호출하지 않아서"가 아니다. 따라서, 헤더에는 선언만 해두고 불필요한 정의를 갖지 않고, 필요한 정의는 실제 소스 파일을 찾도록 하는 것이다. 이래야 불필요한 타입 정의에 대한 의존성을 끌어들이지 않는다. (현재 파일이 포함하는 A.h에 클래스 B를 return 하는 함수는 선언되어 있을 수 있지만, 해당 함수가 불필요하다면 해당 클래스 B가 정의된 소스 파일과는 의존성이 없다.)
  • 선언부와 정의부에 대해 별도 헤더 파일을 제공한다.
    • "클래스를 인터페이스(선언부)와 구현부(정의부)로 쪼개자"는 지침을 제대로 쓸 수 있도록 하려면 헤더 파일이 짝으로 있어야 한다.
    • 하나는 선언부를 위한 헤더 파일이고, 다른 하나는 정의부를 위한 헤더 파일이다.
    • 이 두 파일은 관리도 짝 단위로 해야 한다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바뀌어야 한다.
    • 이를 해결하기 위해 흔히 사용하는 방식이, 정의부에서는 선언부의 헤더 파일을 인클루드 하고, 선언부에서 필요한 헤더를 정의하면 된다.

 

pimpl 관용구를 사용한 핸들 클래스를 사용하는 방법 대신 다른 방법을 사용하고 싶다면, 인터페이스 클래스를 사용하는 방식이 있다. 어떤 기능의 인터페이스를 추상 기본 클래스로 만들어놓고, 이 클래스의 파생 클래스를 만드는 것이다. 파생이 목적이기 때문에 순수 가상 함수만 들어있고, 하나의 가상 소멸자만 있다.

그리고, 인터페이스 클래스의 포인터나 참조자로 프로그래밍하는 것이다. 순수 가상 함수를 포함한 클래스를 인스턴스로 만들지 못하기 때문이다. 대신에 인터페이스의 파생 클래스를 인스턴스로 만들 수 있다. 아무튼 인터페이스 클래스의 포인터나 참조자로 프로그래밍을 하게 되면 인터페이스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없다.(컴파일 의존성이 낮다.)

다만, 인터페이스 클래스를 사용하기 위해서는 팩토리 함수 또는 가상 생성자라 불리는 함수를 만들어 사용한다. 물론, 이 팩토리 함수를 호출할 때는 해당 인터페이스 클래스의 인터페이스를 지원하는 구체적인 클래스가 어딘가에 정의되어 있어야 한다.

 

아무튼 핸들 클래스와 인터페이스 클래스는 구현부로부터 인터페이스를 떨어트려놓음으로써 파일들 사이의 컴파일 의존성을 완화시키는 효과를 준다. 다만, 실행 시간이 추가적으로 들고 객체 한 개당 필요한 저장 공간이 추가적으로 필요하다.

핸들 클래스는 실제 구현부의 객체까지 접근할 때 한 단계씩 요구되는 간접화 연산이 증가하게 된다. 그리고, 객체 한 개씩 저장하는 데 필요한 메모리 크기에 구현부 포인터의 크기가 더해진다. 또한, 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 어디선가 초기화를 해야 한다. 결국 동적 메모리 할당이 발생해 오버헤드가 발생한다.

인터페이스 클래스는 호출되는 함수가 전부 가상 함수라는 약점이 있다. 가상 함수 호출이 될 때마다 가상 테이블을 점프하는 비용이 소모된다. 파생 클래스도 모두 가상 테이블 포인터를 갖는다.

마지막으로, 핸들 클래스와 인터페이스 클래스 모두 인라인 함수의 도움을 제대로 끌어내기 힘들다. 인라인 함수는 대개 헤더 파일에 두어야 하는데, 핸들 클래스와 인터페이스 클래스는 사용자로부터 구현부를 숨기는 것에 중점을 둔 설계이기 때문에 인라인과 어울리지 않는다.

 

하지만, 위의 단점들은 미래를 대비하는 느낌으로 필수적이다. 시간을 조금 더 쓰는 게 낫다. 구현부가 바뀌었을 때 사용자에게 미치는 파급 효과를 생각해야 한다. 핸들 클래스와 인터페이스 클래스로 인해 시간에 문제가 발생한다는 이슈는 발생하지 않을 것이다.

 


이번 장에서는 항목 수는 적었지만 참 내용들이 특히 어려웠고 길었다. 그만큼 중요하고 어려운 것이라 생각이 된다. 구현 부분의 내용은 항상 생각하도록 해야겠다.

728x90