C++ 인라인 함수

함수를 호출하면 프로그램은 함수에 전달할 인자를 준비하고, 함수의 코드를 실행한 뒤 다시 호출한 위치로 돌아옵니다.

이러한 함수 호출에는 일정한 비용이 발생합니다. 함수의 크기가 매우 작고 자주 호출된다면 함수 호출에 필요한 작업이 함수 본문을 실행하는 시간보다 더 커질 수도 있습니다.

C++에서는 작은 함수의 호출 비용을 줄이기 위해 함수 인라인화(Function Inlining)라는 최적화가 사용됩니다.

이번 글에서는 먼저 매크로를 함수처럼 사용하는 방법을 살펴보고, 매크로 함수의 문제점과 C++ 인라인 함수의 특징을 알아보겠습니다.

목차

  1. 매크로 함수란?
  2. 매크로 함수의 치환 과정
  3. 매크로 함수의 특징
  4. 매크로에서 괄호가 필요한 이유
  5. 매크로 함수와 부작용
  6. 함수 인라인화란?
  7. C++ 인라인 함수
  8. 실제 인라인화는 컴파일러가 결정한다
  9. inline 함수와 헤더 파일
  10. 매크로 함수와 인라인 함수 비교
  11. 정리

매크로 함수란?

앞선 글에서 살펴본 것처럼 전처리기는 컴파일에 앞서 #define과 같은 전처리 지시문을 처리합니다.

#define을 사용하면 특정한 이름을 다른 코드로 치환하는 매크로를 정의할 수 있습니다. 매크로에 매개변수와 같은 형태를 추가하면 함수처럼 사용할 수도 있습니다.

#include <iostream>

#define Square(x) ((x) * (x))

int main()
{
    std::cout << Square(3) << '\n';
}

위 코드에서 Square는 함수처럼 보이지만 실제 C++ 함수는 아닙니다.

다음 코드가 매크로의 정의입니다.

#define Square(x) ((x) * (x))

Square(x) 형태의 코드를 발견하면 전처리기는 x에 전달된 코드를 매크로 본문의 x에 대입해 치환합니다.

매크로는 일반적으로 다른 이름과 쉽게 구분할 수 있도록 SQUARE처럼 대문자로 작성하기도 합니다. 이 글에서는 함수와 비교하기 쉽도록 Square라는 이름을 사용하겠습니다.


매크로 함수의 치환 과정

전처리기는 다음 코드에 작성된 Square(3)을 확인합니다.

std::cout << Square(3) << '\n';

그런 다음 매크로에 정의된 내용으로 해당 부분을 치환합니다.

std::cout << ((3) * (3)) << '\n';

전처리 결과를 개념적으로 나타내면 다음과 같습니다.

#include <iostream>

int main()
{
    std::cout << ((3) * (3)) << '\n';
}

실제로는 iostream 헤더의 내용도 현재 번역 단위에 포함되지만, 여기서는 매크로가 치환되는 부분만 간단하게 표현했습니다.

치환된 코드를 컴파일하면 3 * 3이 계산되므로 다음 결과가 출력됩니다.

9

매크로 함수의 특징

매크로 함수는 일반 함수와 비슷한 형태로 사용하지만 동작 방식은 다릅니다.

  • 컴파일 전에 전처리기가 처리합니다.
  • 호출 부분이 매크로에 정의된 코드로 치환됩니다.
  • 실제 함수 호출과 반환 과정이 발생하지 않습니다.
  • 함수의 매개변수와 같은 자료형 검사를 수행하지 않습니다.
  • 전달한 코드가 매크로 본문에 여러 번 포함될 수 있습니다.

매크로 함수는 특정 자료형으로 매개변수를 선언하지 않습니다. 따라서 다음과 같이 정수뿐 아니라 실수도 전달할 수 있습니다.

std::cout << Square(3) << '\n';
std::cout << Square(2.5) << '\n';

코드가 정상적으로 치환되고 곱셈 연산을 수행할 수 있다면 컴파일할 수 있습니다.

이러한 특징은 여러 자료형에 사용할 수 있다는 장점이 있지만, 일반 함수처럼 매개변수의 자료형을 검사하지 않기 때문에 예상하지 못한 코드가 만들어질 수도 있습니다.


매크로에서 괄호가 필요한 이유

매크로 함수를 작성할 때는 매개변수와 전체 표현식을 괄호로 묶는 것이 중요합니다.

다음과 같이 괄호 없이 매크로를 정의했다고 가정해 보겠습니다.

#define Square(x) x * x

이 매크로에 1 + 2를 전달합니다.

int result = Square(1 + 2);

전처리기가 매크로를 치환하면 다음 코드가 만들어집니다.

int result = 1 + 2 * 1 + 2;

곱셈이 덧셈보다 먼저 계산되므로 결과는 9가 아니라 5가 됩니다.

매개변수를 괄호로 묶으면 이 문제를 방지할 수 있습니다.

#define Square(x) ((x) * (x))

같은 코드는 다음과 같이 치환됩니다.

int result = ((1 + 2) * (1 + 2));

두 덧셈이 먼저 계산된 뒤 곱셈이 수행되므로 의도한 결과인 9를 얻을 수 있습니다.

매크로 함수에서는 각 매개변수뿐 아니라 매크로 본문의 전체 표현식도 괄호로 묶는 것이 안전합니다. 다만 괄호를 올바르게 사용해도 매크로 함수의 모든 문제가 해결되는 것은 아닙니다.


매크로 함수와 부작용

매크로 함수의 매개변수에 변수의 값을 변경하는 표현식을 전달하면 문제가 발생할 수 있습니다.

int number = 3;
int result = Square(++number);

전처리기는 Square(++number)을 다음과 같은 코드로 치환합니다.

int result = ((++number) * (++number));

매크로의 본문에서 x가 두 번 사용되므로 전달한 ++number도 두 번 나타납니다.

하나의 표현식 안에서 같은 변수의 값을 여러 번 변경하지만 두 연산의 실행 순서가 정해져 있지 않으므로, 이 코드는 정의되지 않은 동작(Undefined Behavior)을 일으킵니다.

실행 결과를 예측할 수 없으며, 컴파일러나 최적화 설정에 따라 서로 다른 결과가 나타날 수도 있습니다.

일반 함수를 사용하면 전달할 인자가 함수 호출 전에 한 번만 평가됩니다.

int Square(int x)
{
    return x * x;
}

int number = 3;
int result = Square(++number);

위 코드에서는 ++number가 한 번 실행되고, 그 결과가 함수의 매개변수 x에 전달됩니다.

매크로 함수에 증감 연산자, 대입 연산자 또는 함수 호출처럼 상태를 변경할 수 있는 표현식을 전달하지 않는 것이 좋습니다. 단순한 계산을 함수처럼 작성하려면 매크로보다 일반 함수나 인라인 함수를 사용하는 편이 안전합니다.


함수 인라인화란?

일반적인 함수 호출에서는 호출할 함수로 실행 흐름을 이동하고, 함수 실행을 마친 뒤 다시 호출한 위치로 돌아오는 과정이 필요합니다.

함수 호출
    ↓
인자 전달
    ↓
함수 본문 실행
    ↓
호출한 위치로 복귀

함수 인라인화는 함수 호출 대신 함수의 코드를 호출 위치에 배치하는 최적화 방법입니다.

다음과 같은 함수가 있다고 가정해 보겠습니다.

int Square(int x)
{
    return x * x;
}

int result = Square(3);

컴파일러가 이 함수를 인라인화하면 개념적으로 다음과 비슷한 코드가 만들어질 수 있습니다.

int result = 3 * 3;

함수 호출 과정이 사라지므로 호출과 반환에 필요한 작업을 줄일 수 있습니다. 컴파일러가 전달된 값을 직접 확인할 수 있어 추가적인 최적화를 적용하기 쉬워지는 장점도 있습니다.

다만 함수를 호출하는 모든 위치에 함수 본문이 들어가면 생성되는 코드의 크기가 증가할 수 있습니다. 코드가 커지면 명령어 캐시 사용 측면에서 오히려 불리해질 수도 있습니다.

따라서 모든 함수를 인라인화하는 것이 항상 더 빠른 것은 아닙니다.


C++ 인라인 함수

C++에서는 함수 선언 앞에 inline 키워드를 작성할 수 있습니다.

#include <iostream>

inline int Square(int x)
{
    return x * x;
}

int main()
{
    std::cout << Square(3) << '\n';
}

Square는 매크로가 아니라 정수 하나를 전달받아 정수를 반환하는 일반 C++ 함수입니다.

따라서 컴파일러는 다음 내용을 확인할 수 있습니다.

  • 함수에 전달한 인자의 자료형
  • 매개변수의 자료형
  • 함수에서 반환하는 값의 자료형
  • 함수 호출에 전달한 인자의 개수

잘못된 자료형을 전달하거나 필요한 인자의 개수가 맞지 않으면 컴파일러가 오류를 확인할 수 있습니다.

inline int Square(int x)
{
    return x * x;
}

int result = Square(3);

또한 인자로 전달한 표현식은 일반적인 함수 호출 규칙에 따라 한 번 평가된 뒤 매개변수에 전달됩니다. 이 때문에 매크로 함수보다 인자를 안전하게 처리할 수 있습니다.


실제 인라인화는 컴파일러가 결정한다

inline 키워드를 작성했다고 해서 함수가 반드시 인라인화되는 것은 아닙니다.

컴파일러는 다음과 같은 요소를 고려해 실제 인라인화 여부를 결정할 수 있습니다.

  • 함수 본문의 크기
  • 함수가 호출되는 횟수와 위치
  • 함수 내부 제어문의 복잡도
  • 재귀 호출 여부
  • 빌드의 최적화 설정
  • 인라인화로 증가하는 코드 크기

함수의 크기가 크거나 인라인화의 이점이 적다고 판단되면 inline 키워드가 있어도 일반적인 함수 호출 형태로 처리할 수 있습니다.

반대로 inline 키워드를 작성하지 않은 함수도 컴파일러가 필요하다고 판단하면 인라인화할 수 있습니다.

int Add(int a, int b)
{
    return a + b;
}

위 함수에는 inline 키워드가 없지만, 함수가 작고 단순하므로 최적화 과정에서 인라인화될 가능성이 있습니다.

inline은 컴파일러에 인라인화를 요청할 수 있지만 명령하지는 않습니다. 현대 C++ 컴파일러는 최적화 설정과 코드의 사용 상황을 분석해 실제 인라인화 여부를 결정합니다.


inline 함수와 헤더 파일

inline 키워드는 실행 성능과 관련된 인라인화 요청 외에도 C++ 코드의 구성과 관련된 중요한 역할을 합니다.

일반 함수를 헤더 파일에 정의하고 여러 .cpp 파일에서 그 헤더를 포함하면 각 번역 단위에 같은 함수 정의가 들어갑니다.

Math.h

#pragma once

int Square(int x)
{
    return x * x;
}

Main.cppGame.cpp에서 이 헤더를 모두 포함하면 두 번역 단위에 Square 함수가 각각 정의됩니다.

Main.cpp + Math.h → Square 함수 정의 포함 → Main.obj
Game.cpp + Math.h → Square 함수 정의 포함 → Game.obj

일반적인 외부 연결 함수가 여러 번역 단위에 중복으로 정의되면 링크 오류가 발생할 수 있습니다.

함수에 inline을 지정하면 동일한 정의를 여러 번역 단위에서 사용할 수 있습니다.

Math.h

#pragma once

inline int Square(int x)
{
    return x * x;
}

이제 여러 .cpp 파일에서 Math.h를 포함할 수 있습니다.

Main.cpp

#include "Math.h"

int main()
{
    int result = Square(3);
}

Game.cpp

#include "Math.h"

int GetArea()
{
    return Square(5);
}

각 번역 단위에 포함되는 인라인 함수의 정의는 서로 같아야 합니다. 이 조건을 지키면 여러 소스 파일이 같은 함수 정의를 사용할 수 있습니다.

클래스 정의 안에서 함수 본문까지 작성한 멤버 함수도 이러한 인라인 함수 규칙을 적용받습니다.

class Vector2
{
public:
    float GetX() const
    {
        return x;
    }

private:
    float x = 0.0f;
};

GetX 함수는 클래스 정의 안에서 정의되었으므로 별도로 inline 키워드를 작성하지 않아도 인라인 함수로 취급됩니다.

여기서 인라인 함수로 취급된다는 것은 반드시 함수 호출이 제거된다는 의미가 아닙니다. 여러 번역 단위에 동일한 함수 정의를 둘 수 있다는 언어 규칙이 적용된다는 의미도 포함합니다.


매크로 함수와 인라인 함수 비교

구분 매크로 함수 인라인 함수
처리 시점 전처리 단계 컴파일 단계
실체 코드를 치환하는 매크로 일반 C++ 함수
자료형 검사 수행하지 않음 수행함
인자 평가 본문에 사용된 횟수만큼 코드가 반복될 수 있음 각 인자를 한 번 평가해 매개변수로 전달함
실제 인라인화 전처리기가 호출 위치에 코드를 치환함 컴파일러가 최적화 여부를 결정함
헤더 정의 전처리 지시문으로 사용 가능 동일한 정의를 여러 번역 단위에서 사용 가능
권장 용도 조건부 컴파일 등 전처리가 필요한 경우 자료형 검사가 필요한 함수 작성

단순한 계산을 함수 형태로 작성하려면 매크로 함수보다 일반 함수나 인라인 함수를 우선 사용하는 것이 좋습니다.

현대 C++에서는 함수 템플릿과 constexpr 함수도 사용할 수 있으므로, 여러 자료형을 지원하기 위해 매크로 함수에 의존해야 하는 경우도 줄어들었습니다.


정리

  • 매크로 함수는 함수 호출과 비슷한 구문을 사용하지만 실제 C++ 함수는 아닙니다.
  • 전처리기는 매크로 함수의 호출 부분을 매크로에 정의된 코드로 치환합니다.
  • 매크로 함수는 매개변수의 자료형을 검사하지 않습니다.
  • 매크로의 매개변수와 전체 표현식에는 연산 순서 문제를 줄이기 위해 괄호를 작성해야 합니다.
  • 매크로 본문에서 매개변수를 여러 번 사용하면 전달한 표현식도 여러 번 나타납니다.
  • 매크로 함수에 증감 연산과 같이 상태를 변경하는 표현식을 전달하면 정의되지 않은 동작이 발생할 수 있습니다.
  • 함수 인라인화는 함수 호출 대신 함수의 코드를 호출 위치에 배치하는 최적화 방법입니다.
  • 인라인 함수는 일반 C++ 함수이므로 매개변수와 반환값의 자료형을 검사합니다.
  • inline 키워드를 사용해도 실제 함수 인라인화 여부는 컴파일러가 결정합니다.
  • inline 키워드가 없는 함수도 컴파일러의 판단에 따라 인라인화될 수 있습니다.
  • inline 키워드는 동일한 함수 정의를 여러 번역 단위에서 사용할 수 있도록 허용하는 역할도 합니다.
  • 클래스 정의 안에서 정의한 멤버 함수는 일반적으로 인라인 함수 규칙을 적용받습니다.
  • 단순한 계산을 함수처럼 작성할 때는 매크로보다 일반 함수나 인라인 함수를 사용하는 것이 안전합니다.

이전 글: C++ 프로그램은 어떻게 실행 파일이 될까? 컴파일과 링크 과정

다음 글: C++ 타입 별칭이란? typedef와 using 사용법 및 차이점

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다