C++ 고급 문법 (3) — inline 함수(Inline Function)

작은 함수를 헤더에 정의했더니 multiple definition 링크 에러가 나고, 앞에 inline을 붙이자 사라진다. 많은 교재는 inline을 “호출 대신 본문을 끼워 넣으라는 요청”으로 설명하지만, 현대 컴파일러에서 실제로 인라인 확장(inline expansion)을 할지는 최적화기가 혼자 정한다. 지금 inline 키워드가 보장하는 것은 여러 번역 단위(translation unit)에 같은 정의가 있어도 된다는 링크 규칙이다. 이 글에서는 g++ 13.3이 만든 어셈블리와 심볼 테이블을 보며 inline이 실제로 하는 일과 하지 않는 일을 정리한다.

이 시리즈의 다른 글

inline 키워드와 인라인 확장은 별개다

inline int add_inline(int a, int b) { return a + b; }
int        add_plain(int a, int b)  { return a + b; }

int use_inline(int x) { return add_inline(x, 1); }
int use_plain(int x)  { return add_plain(x, 2); }

-O0에서는 inline을 붙인 함수도 그대로 call된다.

$ g++ -std=c++20 -O0 -S -o - opt.cpp | c++filt | grep -E '^[a-z_].*:|call'
add_inline(int, int):
add_plain(int, int):
use_inline(int):
	call	add_inline(int, int)
use_plain(int):
	call	add_plain(int, int)

-O2에서는 inline이 없는 add_plain도 펼쳐져 leal 한 줄이 된다. 더 이상 쓰이지 않는 add_inline은 본문조차 생성되지 않았다.

$ g++ -std=c++20 -O2 -S -o - opt.cpp | c++filt | grep -vE '^\s*\.|^\.L|endbr64|^[0-9]+:'
add_plain(int, int):
	leal	(%rdi,%rsi), %eax
	ret
use_inline(int):
	leal	1(%rdi), %eax
	ret
use_plain(int):
	leal	2(%rdi), %eax
	ret

inline의 실제 의미: ODR 예외

단일 정의 규칙(ODR, One Definition Rule)상 일반 함수는 프로그램 전체에서 정의가 하나여야 한다. 헤더에 정의하고 두 곳에서 include하면 링크가 깨진다.

#pragma once
int clamp01(int v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }   // inline 없음
#include "util.h"
int from_a(int v) { return clamp01(v); }
#include "util.h"
int from_b(int v) { return clamp01(v); }
int main() { return from_b(5); }
$ g++ -std=c++20 -Wall -o app a.cpp b.cpp
/usr/bin/ld: /tmp/cc4Rjcvl.o: in function `clamp01(int)':
b.cpp:(.text+0x0): multiple definition of `clamp01(int)'; /tmp/ccykmBKt.o:a.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

inline을 붙이면 심볼이 약한 심볼(W)로 바뀌어 링커가 중복 정의 중 하나만 남긴다.

#pragma once
inline int clamp01(int v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
$ g++ -std=c++20 -Wall -o app a_fix.cpp b_fix.cpp && echo ok
ok
$ g++ -std=c++20 -Wall -c a_fix.cpp b_fix.cpp
$ nm -C a_fix.o | grep clamp01
0000000000000000 W clamp01(int)

암시적으로 inline인 함수

struct Vec {
    int x, y;
    int dot(const Vec& o) const { return x * o.x + y * o.y; }  // 클래스 안 정의: 암시적 inline
    int len2() const;
};
int Vec::len2() const { return dot(*this); }                    // 클래스 밖 정의: inline 아님

constexpr int cube(int v) { return v * v * v; }                 // constexpr 함수: 암시적 inline

int use(int n)
{
    Vec v{n, n};
    return v.len2() + cube(n);
}
$ g++ -std=c++20 -O0 -c implicit.cpp
$ nm -C implicit.o | grep -E 'Vec|cube|use'
0000000000000025 T use(int)
0000000000000000 W cube(int)
0000000000000000 W Vec::dot(Vec const&) const
0000000000000000 T Vec::len2() const
정의 형태암시적 inline
클래스 본문 안에서 정의한 멤버 함수예
클래스 밖에서 정의한 멤버 함수아니오 (헤더에 두려면 inline 필요)
constexpr/consteval 함수예
함수 템플릿inline은 아니지만 같은 ODR 예외를 받는다
= default로 선언과 동시에 정의한 특수 멤버 함수예

정적 지역 변수와 inline 변수

헤더 함수를 inline 대신 static으로 만들어도 링크는 통과하지만, 번역 단위마다 별도 복사본이 생긴다는 점이 다르다.

#pragma once
inline int next_id()        { static int n = 0; return ++n; }   // 프로그램 전체에 하나
static int next_id_static() { static int n = 0; return ++n; }   // 번역 단위마다 하나

inline int g_inline_var = 0;   // C++17 inline 변수: 헤더에 정의해도 하나
#include "counter.h"
void bump_c1() { next_id(); next_id_static(); ++g_inline_var; }
#include "counter.h"
#include <cstdio>
void bump_c1();
int main()
{
    bump_c1(); bump_c1();
    std::printf("next_id=%d next_id_static=%d g_inline_var=%d\n",
                next_id(), next_id_static(), g_inline_var);
}
$ g++ -std=c++20 -Wall -o counter cmain.cpp c1.cpp
$ ./counter
next_id=3 next_id_static=1 g_inline_var=2

next_id는 두 파일에서 같은 카운터를 공유해 3이 되고, next_id_static은 cmain.cpp 쪽 카운터만 세어 1이다. C++17 inline 변수도 같은 규칙으로 헤더에 정의해 하나를 공유한다.

인라인 확장 제어하기

표준에는 확장을 강제하는 방법이 없으므로 GCC/Clang 속성(attribute)을 쓴다. MSVC는 __forceinline/__declspec(noinline)이다.

__attribute__((noinline)) int add_noinline(int a, int b) { return a + b; }

__attribute__((always_inline)) inline int add_always(int a, int b) { return a + b; }

int use_noinline(int x) { return add_noinline(x, 1); }
int use_always(int x)   { return add_always(x, 2); }
$ g++ -std=c++20 -O2 -S -o - attr.cpp | c++filt | grep -vE '^\s*\.|^\.L|endbr64|^[0-9]+:'
add_noinline(int, int):
	leal	(%rdi,%rsi), %eax
	ret
use_noinline(int):
	movl	$1, %esi
	jmp	add_noinline(int, int)
use_always(int):
	leal	2(%rdi), %eax
	ret

noinline은 -O2에서도 호출이 남는다(꼬리 호출이라 jmp). always_inline은 -O0에서도 펼쳐진다.

$ g++ -std=c++20 -O0 -S -o - attr.cpp | c++filt | grep -E '^[a-z_].*:|call'
add_noinline(int, int):
use_noinline(int):
	call	add_noinline(int, int)
use_always(int):

주소를 취하면 본문이 남는다

inline int twice(int v) { return v * 2; }

int direct(int x) { return twice(x); }

int (*get_ptr())(int) { return &twice; }   // 주소를 꺼내 간다
$ g++ -std=c++20 -O2 -S -o - addr.cpp | c++filt | grep -vE '^\s*\.|^\.L|endbr64|^[0-9]+:'
twice(int):
	leal	(%rdi,%rdi), %eax
	ret
direct(int):
	leal	(%rdi,%rdi), %eax
	ret
get_ptr():
	leaq	twice(int)(%rip), %rax
	ret

direct 안에서는 펼쳐졌지만 함수 포인터가 가리킬 대상이 필요하므로 twice 본문도 따로 생성된다.

매크로 대신 inline 함수

#include <cstdio>

#define SQUARE_M(x) ((x) * (x))
inline int square(int x) { return x * x; }

int main()
{
    int i = 3, j = 3;
    int a = SQUARE_M(i++);   // i++가 두 번 평가된다
    int b = square(j++);
    std::printf("macro : a=%d i=%d\n", a, i);
    std::printf("inline: b=%d j=%d\n", b, j);
}
$ g++ -std=c++20 -Wall -o macro macro.cpp
macro.cpp: In function ‘int main()’:
macro.cpp:9:23: warning: operation on ‘i’ may be undefined [-Wsequence-point]
    9 |     int a = SQUARE_M(i++);   // i++가 두 번 평가된다
      |                       ^
macro.cpp:3:23: note: in definition of macro ‘SQUARE_M’
    3 | #define SQUARE_M(x) ((x) * (x))
      |                       ^
$ ./macro
macro : a=12 i=5
inline: b=9 j=4

매크로는 인수 표현식을 텍스트로 복사해 i++가 두 번 실행된다. inline 함수는 인수를 한 번만 평가하고 타입 검사도 받는다.

정의가 다른 inline 함수

링커가 중복 정의를 하나로 합칠 수 있는 것은 “모든 정의가 같다”는 전제가 있기 때문이다. 정의가 다르면 ODR 위반이고 진단 없이 미정의 동작이 된다.

inline int version() { return 1; }
int ver_from_v1() { return version(); }
inline int version() { return 2; }   // 같은 이름, 다른 정의 (ODR 위반)
int ver_from_v2() { return version(); }
#include <cstdio>
int ver_from_v1();
int ver_from_v2();
int main() { std::printf("v1=%d v2=%d\n", ver_from_v1(), ver_from_v2()); }
$ g++ -std=c++20 -Wall -O0 -o t vmain.cpp v1.cpp v2.cpp && ./t
v1=1 v2=1
$ g++ -std=c++20 -Wall -O0 -o t vmain.cpp v2.cpp v1.cpp && ./t
v1=2 v2=2
$ g++ -std=c++20 -Wall -O2 -o t vmain.cpp v1.cpp v2.cpp && ./t
v1=1 v2=2

-O0에서는 링크 순서상 먼저 나온 정의 하나가 양쪽에서 쓰이고, -O2에서는 각 파일 안에서 펼쳐져 결과가 갈린다. 빌드 옵션만 바꿔도 동작이 달라지는 셈이다.

주의사항

상황문제대응
성능을 위해 inline을 붙임최적화기는 키워드와 무관하게 판단한다프로파일링 후 필요하면 always_inline, 아니면 컴파일러에 맡긴다
헤더에 함수 정의multiple definitioninline 추가 (static은 복사본이 생김)
헤더에 전역 변수·정적 멤버 정의중복 정의 또는 .cpp에 별도 정의 필요C++17 inline 변수
같은 이름의 inline 함수를 파일마다 다르게 정의진단 없는 ODR 위반, 최적화 수준에 따라 결과가 바뀜파일 전용 함수는 익명 네임스페이스(anonymous namespace)에 둔다
공개 라이브러리 헤더의 inline 함수본문이 사용자 바이너리에 박혀 라이브러리만 교체해서는 수정이 반영되지 않음ABI 안정성이 필요한 함수는 .cpp에 정의
큰 함수에 always_inline 남용코드 크기 증가, 명령어 캐시 압박핫 경로의 작은 함수에만 사용

마무리

  • 인라인 확장 여부는 최적화 수준과 컴파일러 휴리스틱이 정하며 inline 키워드는 거의 영향이 없다.
  • inline의 실질적 의미는 “여러 번역 단위에 같은 정의가 있어도 된다”는 ODR 예외다.
  • inline 함수의 정적 지역 변수와 C++17 inline 변수는 프로그램 전체에서 하나다.
  • 확장을 강제하거나 막으려면 always_inline/noinline 속성을 쓴다.

참고

답글 남기기