30: 인라인 함수는 미주알고주알 따져서 이해해 두자
인라인 함수는 함수 호출 비용이 면제되는 것외에 다른 이점이 있다. 컴파일러 최적화는 함수 호출이 없는 코드가 연속적으로 이어지는 구간에 적용되도록 설계되었기 때문에, 인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별(context_sepcific) 최적화를 걸기가 용이해진다. 일반적인 함수 호출에는 이런 최적화를 적용하지 않는다.
하지만 인라인 함수도 공짜는 아니다. 목적 코드의 크기가 커진다. 메모리가 제한된 컴퓨터에서 아무 생각 없이 인라인을 남발했다가는 프로그램 크기가 그 기계에서 쓸 수 있는 공간을 넘어버릴 수도 있다. 가상 메모리를 쓰는 환경일지라도 인라인 함수로 인해 부풀려진 코드는 성능의 걸림돌이 되기 쉽다. 페이징 횟수가 늘어나고, 명령어 캐시 적중률이 떨어질 가능성도 높아진다.
본문 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 본문에 대해 만들어지는 코드의 크기가 함수 호출문에 대해 만들어지는 코드보다 작아질 수 있다. 이런 경우에는 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 높아진다.
inline은 컴파일러에 대해 '요청'을 하는 것이지, '명령'이 아니다. inline을 붙이지 않아도 그냥 눈치껏 되는 경우도 있고 명시적으로 할 수도 있다. 우선 암시적인 방법을 알아보자. 클래스 정의 안에 함수를 바로 정의해 넣으면 컴파일러는 그 함수를 인라인 함수 후보로 찍는다.
class Person {
public:
int age() const { return theAge; } // 암시적인 인라인 요청
private:
int theAge;
};
명시적인 방법은 함수 정의 앞에 inline 키워드를 붙이는 것이다. 한 예로 표준 라이브러리의 max 템플릿이 이렇게 구현되어 있다.
template<typename T>
inline const T& std::max(const T& a, const T& b)
{
return a < b ? b : a;
}
max가 템플릿이라는 점 때문에 '인라인 함수와 템플릿은 대개 헤더 파일안에 정의한다'라는 이야기가 생각나기도 한다. 이 점을 오해해서 함수 템플릿은 반드시 인라인 함수이여야 한다고 결론 내리는 것은 맞지 않다.
인라인 함수는 대체적으로 헤더 파일에 들어 있어야 하는 게 맞다. 왜냐하면 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문이다.
템플릿 역시, 대체적으로 헤더 파일에 들어 있어야 맞다. 템플릿이 사용되는 부분에서 해당 템플릿을 인스턴스로 만들려면 그것이 어떻게 생겼는지를 컴파일러가 알아야 하기 때문이다.
그런데 템플릿 인스턴스화는 인라인과 완전히 별개로, 하등의 관련이 없다. 이 템플릿으로부터 만들어지는 모든 함수가 인라인 함수였으면 싶은 경우에 그 템플릿에 inline을 붙여 선언하는 것이 끝이다.
대부분의 컴파일러의 경우, 아무리 인라인 함수로 선언되어 있어도 자신이 보기에 복잡한 함수는 절대로 인라인 확장의 대상에 넣지 않는데다가(루프가 들어 있다거나 재귀 함수인 경우가 예) 정말 간단한 함수라 할지라도 가상 함수 호출 같은 것은 절대로 인라인해 주지 않는다.
결론은 인라인 함수가 실제로 인라인되느냐 안 되느냐의 여부는 전적으로 개발자가 사용하는 빌드 환경에 달렸다는 점이다. 그 중에서도 칼자루를 쥐고 있는 쪽은 컴파일러이다.
확실한 인라인 함수도 '어떻게 호출하냐'에 따라 인라인되기도 하고 안 되기도 한다.
inline void f() {...}
void (*pf)() = f; // pf는 f를 가리키는 함수 포인터
f(); // 이 호출은 인라인 된다. 평범한 함수 호출
pf(); // 이 호출은 인라인 안 된다. 함수 포인터를 통해 호출
생성자와 소멸자는 인라인하기에 그리 좋지 않은 함수이다. 사실 더 안 좋다.
class Base {
public:
...
private:
string bm1, bm2;
};
class Derived : public Base {
public:
Derived() {} // 생성자가 비어 있지만 정말 비어있을까?
private:
string dm1, dm2, dm3;
};
C++는 객체가 생성되고 소멸될 때 일어나는 일들에 대해 여러 가지 보장을 준비해 놓는다. new를 하면 이때 동적으로 만들어지는 객체를 생성자가 자동으로 초기화해 주는 것도 그렇고, delete를 하면 이에 대응되는 소멸자가 호출되는 것도 C++가 깔아둔 보장이다. 객체를 생성하면 그 객체의 기본 클래스 부분과 그 객체의 데이터 멤버들이 자동으로 생성되며, 그 객체가 소멸될 때 이에 반대되는 순서로 소멸 과정이 저절로 이루어지는 것도 마찬가지다. 또한 C++는 객체가 생성되는 도중에 예외가 던져지더라도, 이미 생성이 완료된 부분만큼은 여러분 손길 없이도 말끔히 소멸되도록 보장한다. 이러한 과정이 소스 코드 어딘가에 들어가 있어야 한다는 결론이 된다. 떄에 따라서는 바로 그 장소가 생성자와 소멸자일 수도 있는 것이다. 그러니까, 비어 있다고 생각되던 Derived 생성자는 사실 어떤 구현환경에서는 다음과 같은 코드로 만들어질 것이라고 상상해 볼 수 있다.
Derived::Derived()
{
Base::Base();
try { dm1.string::string(); }
catch (...) {
Base::~Base();
throw;
}
try { dm2.string::string(); }
catch (...) {
dm1.string::~string();
Base::~Base();
throw;
}
try { dm3.string::string(); }
catch (...) {
dm2.string::~string();
dm1.string::~string();
Base::~Base();
throw;
}
}
Derived 클래스의 생성자는 최소한 자신의 데이터 멤버와 기본 클래스 부분에 대해 생성자를 호출해 주어야 하고, 이들 생성자를 호출해야 하기 때문에 인라인이 사뭇 남감해진다.
라이브러리를 설계한다면 함수를 inline으로 선언할 때 그 영향에 대해 많은 고민을 해야 한다. 어떤 라이브러리에 f라는 인라인 함수가 있고, 이 라이브러리를 쓰는 사용자가 f의 본문을 컴파일해서 응용 프로그램을 만들었다고 가정해 보자. 나중에 이 라이브러리 개발자가 f의 내부를 바꾸겠다고 결정했다면, f를 썼던 사용자들은 슬프지만 죄다 각자의 소스를 다시 컴파일해야 한다.
대부분의 디버거에서 인라인 함수는 비호감의 대상이다. 있지도 않은 함수에 중단점을 걸 수 없기 때문이다.
inline의 기본 전략은 우선, 아무것도 인라인하지 않는다. 아니면 꼭 인라인해야 하는 함수 혹은 정말 단순한 함수에 한해서만 시작하자.
이것만은 잊지 말자!
- 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어두자. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아진다.
- 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 된다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 (1) | 2023.10.31 |
---|---|
[Effective C++] 31: 파일 사이의 컴파일 의존성을 최대로 줄이자 (1) | 2023.10.31 |
[Effective C++] 29: 예외 안정성이 확보되는 그날 위해 싸우고 또 싸우자! (0) | 2023.10.28 |
[Effective C++] 28: 내부에서 사용하는 객체에 대한 '핸들'을 반환하는 코드는 되도록 피하자 (0) | 2023.10.26 |
[Effective C++] 27: 캐스팅은 절약, 또 절약! 잊지 말자 (0) | 2023.10.26 |