인라인 함수

1. 개요
1.1. 인라이닝 강요하기
1.2. 인라이닝 금지하기
2. 일반 함수로 코딩 시
3. 인라인 함수 사용 시
4. 인라이닝의 성능상 의의
5. 인라이닝의 문법적 의의
6. 매크로와의 차이

1. 개요

C/C++ 언어에서 사용할 수 있는 기능으로, 함수 호출 시 별도로 분리된 위치의 레이블로 점프하여 실행되는 일반 함수와는 달리 호출 부분을 함수 전체 코드로 치환하여 컴파일한다. 전처리기의 매크로와 달리 언어 차원에서 지원하기에 일반 함수와 별 다르지 않게 사용할 수 있다.

C99부터 지원하는 기능이다. 이전에는 없었다.[1]

사용방법은 무척 심플한데, inline을 함수 선언시 붙여주면 된다. 다만 이것은 컴파일러에게 주는 힌트일 뿐 항상 인라이닝을 하는 것을 보장하지 않는다. 가령 대부분의 컴파일러는 비용 분석을 통해 인라이닝이 손해라고 판단하면 코드에서 아무리 inline을 붙여도 인라이닝을 포기한다.

참고로, 최신의 컴파일러의 경우는 최적화 기능이 잘 되어 있기에 inline이 붙어 있지 않더라도 인라이닝을 하는게 이득이 된다고 판단되면, 그냥 알아서 인라이닝 처리를 해버린다.

1.1. 인라이닝 강요하기

컴파일러에게 특정 함수의 인라이닝을 강요하는 기능은 원래 표준 C/C++에는 없다. 하지만 주요 컴파일러들은 방언으로 그러한 기능을 제공한다.

  • MSVC의 경우 __forceinline를 함수 선언시 붙여준다. 단 아래의 경우 __forceinline이 붙어도 인라이닝을 하지 않는다.
    • /Ob0으로 컴파일. (최적화 금지, 디버그 빌드 기본옵션)
    • 함수와 호출자가 서로 다른 예외처리 방식을 사용할 경우. 하나가 C++ 예외를, 하나가 SEH를 사용할 경우 발생한다.[2]
    • 가변인수 (va_arg)를 가진 경우.[3]
    • 함수 안에 인라인 어셈블리가 들어가 있고 /Og, /Ox, /O1, /O2로 컴파일하지 않았을 경우.[4]
    • 함수가 재귀함수이고 inline_recursion(on)로 정의되지 않은 경우. inline_recursion(on)가 있다면 기본값으로 16단계까지 중첩하여 인라이닝하며 수치를 바꾸려면 inline_depth(N)을 사용해 N단계로 바꿀 수 있다.
    • (C++) 가상함수이고 가상으로 불린 경우. 만약 가상으로 불리지 않은 경우 (e.g., Derived::virtual_function() 식으로 강제로 베이스 클래스를 지정한 경우) 는 인라이닝 가능하다.[5]
    • (당연하지만) 함수 포인터를 통한 호출.
    • __declspec(naked)로 선언된 함수.[6]
  • gcc의 경우 __attribute__((always_inline))를 함수 선언시 붙여준다.

클래스 선언 내에 정의된 함수들은 자동으로 inline이 붙는 것으로 취급된다. 함수를 선언만 하고 클래스 바깥에서 정의할 경우는 해당사항이 없으므로 선언시 inline을 붙여줘야 한다는 점에 유의.

1.2. 인라이닝 금지하기

중요한 내용은 아니지만, 반대로 컴파일러에게 특정 함수를 인라이닝 하지 말라고 강요하는 기능도 비표준으로 일부 컴파일러에 존재한다. 현대의 컴파일러는 최적화 시에 스스로 판단해서 자동으로 인라이닝을 하기 때문에, 몇몇 이유로[7] 인라이닝을 하지 않고 싶은 함수가 있는 경우 지정해줄 수 있다.

  • MSVC의 경우 __declspec(noinline)를 함수 선언시 붙여준다.
  • gcc의 경우 __attribute__((noinline))를 함수 선언시 붙여준다.

2. 일반 함수로 코딩 시

#!syntax cpp
#include <stdio.h>

void print()
{
    printf("Hello, world!\n");
}

int main()
{
    print();
    return 0;
}

위와 같이 작성된 코드는 실제로 컴파일되면 아래와 같이 실행된다.

1. main 시작점

2. print 함수 시작점으로 점프 (현재 IP를 백업한다)

3. 스택 포인터를 다시 쌓음

4. printf("Hello, world!\n");

5. 스택 포인터를 원래대로 되돌리기

6. main으로 돌아옴 (IP 복귀)

자세한 내용은 인터넷 등지에서 함수 호출 규약 (Calling Convention)을 찾아보면 알 수 있다.

3. 인라인 함수 사용 시

#!syntax cpp
#include <stdio.h>

inline void print()
{
    printf("Hello, world!\n");
}

int main()
{
    print();
    return 0;
}

위 코드는 컴파일 시 아래처럼 바뀌어 컴파일된다.

#!syntax cpp
#include <stdio.h>

int main()
{
    printf("Hello, world!\n");
    return 0;
}

4. 인라이닝의 성능상 의의

보통 브랜칭 및 함수 호출은 근대적인 CPU 파이프라인 구조에서 비교적 비싼 축에 속하는 작업이다. 이를 막고 직접 내용물을 넣으면 호출 코스트를 부담할 필요가 없기 때문에 인라이닝을 하는 것이다. 따라서 간단한 작업 (e.g., 클래스 내 멤버 반환, 덧셈 수행 등)의 경우 인라이닝을 하여 높은 비중의 함수 호출 코스트를 줄이는 것은 분명 매력적인 방법이긴 하다.

다만 인라인의 진짜 가치는 함수 호출 비용을 제거하는 데에 있는 것이 아니라, 인라인 이후 추가적으로 이루어지는 최적화를 가능하게 만드는 것에 있다. 이를테면 두 개의 정수를 받아 더하는 add 함수가 있다고 할 때, add(0, 1)은 누가 봐도 1로 치환 가능하지만 인라인 이전까지는 컴파일러가 이에 대해 알 도리가 없다. 실세계에서는 STL의 사용이 좋은 사례로, 코드를 보면 몇 단계에 걸친 함수 호출이 이루어지지만 실제 어셈블리를 보면 불필요한 로직이 전부 삭제되고 꼭 필요한 명령어 몇개만 남는 것을 볼 수 있다. 물론 전역 최적화의 목적 역시 동일하지만 이 쪽은 드는 시간이 워낙에 큰 관계로...

하지만 무차별적으로 쓸 경우 코드상에서 중복되는 부분이 컴파일 결과 바이너리에 산재하게 되며, 이는 비단 바이너리의 크기만 증가시키는 것이 아니라 브랜칭 예측률 및 instruction 캐시 적중률을 낮추는 효과가 있다. 즉 복잡한 함수를 여러군데에서 인라이닝하면 프로그램 크기는 커질대로 커지고 성능은 오히려 감소하는 현상이 일어난다.

다만 요즘은 컴파일러의 발달로 인해 inline을 쓰든 안 쓰든 상관없이 최적화 중 함수의 인라이닝 여부를 자의적으로 판단하기 때문에 의미가 상당히 퇴색된 것은 사실이다.

5. 인라이닝의 문법적 의의

인라이닝에는 문법적인 의의도 있는데, 바로 ODR (One Definition Rule)을 무시한다는 것이다. 보통 같은 이름의 심볼이 서로 다른 translation unit에 존재하면 링커가 심볼 중복 문제로 인해 에러를 띄우는 반면, 인라인 함수는 여러 파일에 중복 정의가 되든 말든 ODR을 만족한 것으로 간주되어 정상적으로 링크된다. 또한 인라인 함수를 포인터로 취급하거나 인라인 함수 내 static 로컬 변수가 있다든지, const literal이 있다든지 등의 이유로 인라인 함수 본체가 바이너리에 들어가야 하는 상황이 와도, 최종 바이너리에는 정확히 한 카피만 들어가게 된다.

이 점이 유용한 것은 헤더에 함수 정의가 들어가 있는 경우인데, 함수 정의가 들어간 헤더를 여러 translation unit에서 동시에 include할 경우 각 translation unit마다 함수 심볼과 정의가 중복된다. 따라서 보통은 위에서 언급한 대로 링커 에러가 나야 하지만, 인라이닝을 수행하면 그런 문제 없이 정의를 할 수 있다.

C++에서는 클래스 정의 내부에 들어있는 함수 구현의 경우는 자동으로 인라인 취급된다. 보통 단순 getter 및 setter 등의 내용물은 이런 식으로 구현된다. virtual 함수도 inline을 할 수 있는데, 컴파일러 입장에서 실제 바인딩될 함수가 뭔지 모르므로 인라이닝이 불가능하여 보통은 쓸모 없다. 다만 드물게 컴파일러가 바인딩될 함수를 미리 알아챌 수 있는 경우에 한정해 인라이닝이 가능하다.

  • 포인터/레퍼런스가 아닌 concrete object를 가지고 직접 콜할 경우
  • (C++11) 포인터/레퍼런스 타입 상에서 virtual inline 함수가 final이거나, 혹은 포인터/레퍼런스 타입 자체가 final인 경우.

물론 ODR을 무시한다고 해서 제멋대로 선언 가능한 것은 아니다. 인라인 함수가 한 translation unit 안에 여러번 중복되어 정의되면 컴파일 에러가 일어나고, 서로 다른 translation unit 간에 이름만 같고 내용물이 다르게 정의될 경우 잘해봐야 링커 에러, 최악의 경우 UB 크리를 먹으므로 주의할 것. 함수 본체 내 매크로를 심하게 사용할 경우 간혹 일어날 수 있는 현상이다.

6. 매크로와의 차이

매크로와의 차이를 명료하게 설명하기 위해 아래와 같은 예시를 들자.

#!syntax cpp
// 매크로 :
#define MUL(x,y) ((x)*(y))

// 인라인 :
inline int MUL(int x,int y) {
    return x*y;
}

위와 같은 경우 인라인 함수와 매크로 (함수)의 차이는 다음과 같다:

1. 인라인 함수는 타입체크를 해서 인자를 int형 정수로만 받지만, 매크로는 그런 것 없이 무조건 치환한다.

2. 두 함수를 처리하는 처리하는 주체가 다르다.

  • 전처리기(preprocessor)가 일괄적으로 치환하는 것이 매크로이다.[8],
  • 컴파일러가 일반 함수처럼 문법 검사 및 타입체크 등을 하는 것이 인라인 함수이다.

3. 매크로는 전처리기가 무조건 치환하기에 무시할 수 없지만, 인라인 함수의 경우는 진보된 컴파일러가 판단하여 교체해 넣는 것이 오히려 손해라고 판단되면[9], 일반적인 함수로서 작동할 수도 있다. 즉, 기본적으로 inline 구문은 인라인이 선호될 뿐 강제는 아니다.

참고로 매크로 함수는 과거에는 자주 쓰였지만 현재는 최대한 지양해야할 기능으로 여긴다. 타입 체크를 무시하고, 유지보수를 어렵게 만들고, namespace를 무시하기 때문. 윈도우즈 헤더에서 min/max 매크로를 정의해놓은 탓에 std::min/max와 충돌하는 것을 보면 마이크로소프트에 대한 깊은 빡침을 느낄 수 있다. MSVC라면 /DNOMINMAX 옵션이나 NOMINMAX로, MSVC를 제외한 대부분의 컴파일러에서는 #undef 로 이 매크로를 없앨 수 있다.[10]


  1. [1] C Programming A Modern Approach 2nd Edtion 참고
  2. [2] 두 예외처리를 지원하기 위한 함수 콜 구조가 다르기 때문이다.
  3. [3] 콜을 해야 스택 프레임이 쌓인다. 보통 va_arg는 스택 프레임을 포인터로 직접 뒤지는 방식으로 구현되는데, 당연히 콜 없이 인라이닝으로 코드를 삽입해버리면 생기지도 않은 스택 프레임에 접근할 수 없으니 말이 되지 않는다.
  4. [4] 인라이닝을 위해서는 함수 코드에 대해 레지스터를 리디렉션하는 등 상황에 맞는 유연한 기계어 코드 삽입이 필요하다. 함수 내 인라인 어셈블리가 있을 경우 이 역시 수정 대상이 되는데, MSVC의 경우 최적화 옵션 없이는 함부로 인라인 어셈블리 내용을 바꾸지 않으려 들기 때문인 것으로 추정된다.
  5. [5] 아래 문법적 기능 참고.
  6. [6] 이 함수들은 스택 프레임이 불완전하게 형성된다. 위 va_arg와 비슷한 상황.
  7. [7] 주로 ABI 호환성이 엄격하게 정의된 라이브러리나 프레임워크일 경우 그러하다.
  8. [8] MS워드에서 Ctrl+H 를 눌러서 해당 부분을 모조리 다 교체하는 것과 같다
  9. [9] 캐시 효율성(cache hit radio)등의 이유로 그냥 함수로 올려 놓고 쓸 때의 효율이 더 높다고 판단될 경우 등
  10. [10] MSVC는 #undef 매크로를 무시하므로 이 방법을 사용할 수 없다.

최종 확인 버전:

cc by-nc-sa 2.0 kr

Contents from Namu Wiki

Contact - 미러 (Namu)는 나무 위키의 표가 깨지는게 안타까워 만들어진 사이트입니다. (12.52ms)