작은 함수를 헤더에 정의했더니 multiple definition 링크 에러가 나고, 앞에 inline을 붙이자 사라진다. 많은 교재는 inline을 “호출 대신 본문을 끼워 넣으라는 요청”으로 설명하지만, 현대 컴파일러에서 실제로 인라인 확장(inline expansion)을 할지는 최적화기가 혼자 정한다. 지금 inline 키워드가 보장하는 것은 여러 번역 단위(translation unit)에 같은 정의가 있어도 된다는 링크 규칙이다. 이 글에서는 g++ 13.3이 만든 어셈블리와 심볼 테이블을 보며 inline이 실제로 하는 일과 하지 않는 일을 정리한다.
이 시리즈의 다른 글
- C++ 고급 문법 (1) — 함수 오버로딩(Function Overloading)
- C++ 고급 문법 (2) — 함수 템플릿(Function Template)
- C++ 고급 문법 (4) — 함수 객체(Function Object)
- C++ 고급 문법 (5) — 임시 객체(Temporary Object)
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 definition | inline 추가 (static은 복사본이 생김) |
| 헤더에 전역 변수·정적 멤버 정의 | 중복 정의 또는 .cpp에 별도 정의 필요 | C++17 inline 변수 |
| 같은 이름의 inline 함수를 파일마다 다르게 정의 | 진단 없는 ODR 위반, 최적화 수준에 따라 결과가 바뀜 | 파일 전용 함수는 익명 네임스페이스(anonymous namespace)에 둔다 |
| 공개 라이브러리 헤더의 inline 함수 | 본문이 사용자 바이너리에 박혀 라이브러리만 교체해서는 수정이 반영되지 않음 | ABI 안정성이 필요한 함수는 .cpp에 정의 |
큰 함수에 always_inline 남용 | 코드 크기 증가, 명령어 캐시 압박 | 핫 경로의 작은 함수에만 사용 |
마무리
- 인라인 확장 여부는 최적화 수준과 컴파일러 휴리스틱이 정하며
inline키워드는 거의 영향이 없다. inline의 실질적 의미는 “여러 번역 단위에 같은 정의가 있어도 된다”는 ODR 예외다.- inline 함수의 정적 지역 변수와 C++17 inline 변수는 프로그램 전체에서 하나다.
- 확장을 강제하거나 막으려면
always_inline/noinline속성을 쓴다.