[Advanced C++] 15. External Linkage, 비상수 전역변수 사용, inline function

dev.kelvin·2025년 1월 21일
1

Advanced C++

목록 보기
15/74
post-thumbnail

1. External Linkage (외부 연결)

External Linkage

External Linkage는 Internal Linakge와 반대로 링크단계에서 다른 파일에서 식별자들을 링크할 수 있는 개념이다

External Linkage 속성의 식별자들은 Linker에 표시가 된다, Linker는 이를 통해 사용된 식별자를 다른 파일에서의 정의와 연결할 수 있다, 또한 중복된 inline 식별자를 제거하여 하나의 정의만 남긴다

이전에 정리했듯 함수는 기본적으로 External Linkage이다, 따라서 다른 파일의 함수를 호출하기 위해 forward declaration을 하여 컴파일러에게 함수의 존재를 알리고 사용 시 Linker가 함수의 정의와 연결한다

	//foo.cpp
    void foo()
    {
    }
    
    //main.cpp
    void foo();
    
    int main()
    {
    	foo();
    }

만약 이 foo()라는 전역함수가 static과 같은 키워드로 Internal Linkage 속성이라면 함수 정의와 연결이 불가능해 LNK에러가 발생한다

그리고 상수 전역변수는 기본적으로 Internal Linkage이다, 이를 External Linkage로 만들기 위해서는 extern 키워드가 필요하다 (비상수 전역변수는 default가 External Linkage이기 때문에 extern키워드가 필요없다, 하지만 외부 파일에서 사용하려면 extern키워드가 필요하다)

	extern const int a{ 10 }; //External Linkage
    extern constexpr int b{ 20 }; //External Linkage

다른 파일에 정의된 External Linkage 전역 변수들을 사용하기 위해서는 다음과 같이 처리한다

	//foo.h
    extern int a;
    
	//foo.cpp (혹은 그냥 .h에서 extern int a{10};으로 한번에 초기화도 가능, 단 이렇게 하면 #include로는 ODR위배가 되기 때문에 사용할 수 없다, 전방선언으로 사용해야 함)
	int a{ 10 };
    
    //main.cpp
    extern int a;

혹은 foo.h를 include하고 전방선언을 하지 않는 방식으로 사용해도 된다

만약 .h에 Internal Linkage 속성의 식별자들을 선언하고 해당 헤더파일을 #include 한다면 그냥 엑세스가 가능하다 (하지만 이때 External Linkage 속성이라면 여러파일에서 #include 시 LNK error가 발생한다, Internal Linkage속성의 식별자를 여러군데 #include해도 각각 독립적으로 생성되기 때문에 문제가 없지만 External Linkage속성의 식별자는 여러곳에서 #include하면 중복 정의에 걸리기 때문)

따라서 Interanl Linkage속성의 식별자를 #include해서 사용하면 각각 독립적으로 생성되기 때문에 원본 식별자의 값이 수정되지 않는다 (각각 독립적으로 생성된 식별자와 링크되기 때문)

추가로 constexpr 전역변수는 constexpr로 전방선언이 불가능하다

	//foo.cpp
    extern constexpr int a{ 10 };
    
    //main.cpp
    extern constexpr int a; //error

왜냐하면 constexpr은 컴파일타임 상수이기 때문에 컴파일 타임에 초기값을 알아야 한다, 하지만 컴파일 타임에 컴파일러는 다른 파일에 있는 값을 참조할 수 없기 때문에 사용할 수 없는것이다

따라서 그냥 include해서 사용해야 한다 (C++17부터 constexpr은 암묵적으로 inline이기 때문에 ODR위배되지 않음, 단 extern constexpr은 inline 속성이 아니기 때문에 #include하면 에러가 발생함)


2. non-const global variable을 지양해야 하는 이유

non-const global variable을 지양하는 이유

왜 많은 개발자들은 non-const global variable 사용을 지양할까?

가장 큰 이유는 다른 파일이나 다른 함수에서 쉽게 변경될 수 있기 때문이다

	//foo.h
	extern int a;
    
    //foo.cpp
    int a = 42;
   
    //main.cpp 
    int main()
    {
    	a = 200;
    }

이러한 비상수 전역변수들은 예상치 못한곳에서 수정이 쉽기 때문에 프로그래머의 의도와 다른 동작이 쉽게 발생할 수 있다 (위험)

이전에 지역변수는 사용하는곳 가장 가까이에 선언하는게 좋다고 정리했다, 이는 살펴봐야할 코드를 줄일 수 있는 장점이 있기 때문이다

하지만 비상수 전역변수는 어디에서나 엑세스가 가능하기 때문에 살펴봐야할 코드의 양이 많이 늘게 된다 (디버깅에 굉장히 불리함)

또한 비상수 전역변수는 프로그램의 모듈화에 안 좋은 영향을 미친다 (유연함 저하)

가능한 전역변수보다는 로컬변수를 사용하는게 좋다

전역 변수 초기화 순서

전역 변수의 초기화는 main() 호출 이전에 실행되며 프로그램 시작의 일부이다

  1. 정적 초기화 (static initialize)
    static변수나 constexpr과 같은 전역변수의 초기화가 컴파일타임에 먼저 이루어진다 (컴파일 타임 함수로 초기화도 정적초기화임 ex) consteval, constexpr)

  2. 동적 초기화 (dynamic initialize)
    비상수 전역변수의 초기화가 그 다음 런타임에 이루어진다 (ex) 일반 함수 return값으로 초기화 되는 전역변수)

비상수 전역변수를 초기화 할 때 순서의 문제가 발생할 수 있다

	int getA();
    int getB();
    
    int a{ getA() }; //초기활 될 때 b가 초기화 되지 않음
    int b{ getB() };
    
    int getA()
    {
    	return b;
    }
    
    int getB()
    {
    	return 10;
    }

이는 다른 파일에서의 비상수 전역변수 초기화 시 더 큰 문제가 될 수 있다

	//A.cpp
    extern int resultB;
    
    int resultA{ resultB + 1 };
    
    //B.cpp
    extern int resultA;
    
    int resultb{ resultA + 1 };
    
    //main.cpp
    extern int resultA;
    extern int resultB;

resultA, resultB중 어떤것이 먼저 초기화 되는지 명시되어 있지 않기 때문에 의도치 않은 값이 나올 수 있다

따라서 전역변수 동적 초기화는 피하는것이 좋다

그럼에도 불구하고 전역변수를 사용하는 이유는?

많이 사용하지는 않지만 사용하는 케이스가 존재한다

프로그램에서 해당 변수가 단 한개가 있어야 하며 프로그램 전체에서 해당 변수를 사용해야 하는 상황이면 비상수 전역변수를 사용한다

꼭 사용해야 한다면 전역변수의 prefix로 g를 붙히는것도 나쁘지 않다 (명명충돌 방지 및 전역변수임을 알리기)

그리고 namespace안에 넣어서 사용하자

	constexpr int a{ 10 }; //X
    
    namespace Test //O
    {
    	constexpr int a{ 10 };
    }
    
    Test::a;

더욱 좋은 방법은 namespace로 감싼 전역변수도 캡슐화를 통해 안전하게 Get/Set하는 방식이다

	//test.cpp
    namespace Test
    {
    	constexpr int a{ 10 };
    }
    
    int getA()
    {
    	return Test::a;
    }
    
    //main.cpp
    int getA();
    
    int main()
    {
    	getA();
    }
    

3. inline function

	int min(int a, int b)
    {
    	return (a < b) ? a : b;
    }

함수가 호출되게 되면 CPU는 함수 호출이 끝나고 돌아갈 주소, 레지스터 값들을 저장해야 한다

그리고 매개변수를 인스턴스화 하고 초기화하며 함수 본문으로 코드 이동, 반환값 복사 출력등이 발생한다

이러한 추가 작업들을 오버헤드라고 한다

아주 작은 기능을 담당하는 함수의 오버헤드는 실제 함수 로직이 실행되는 시간보다 더 큰 코스트가 들 수 있다

이러한 작은 함수들을 굉장히 자주 호출하는 경우 inline에 비해 상당한 성능 저하가 발생할 수 있다

inline

C++ 컴파일러는 이러한 함수 호출 오버헤드를 줄이기 위한 inline expansion (인라인 확장) 기술을 제공한다

쉽게 말하면 함수 호출을 호출된 함수의 정의로 바꾸는 프로세스이다

위의 min()이 호출될때마다 해당 호출부의 코드를 (a < b) ? a : b로 바꾸는것이다, 이로서 함수 호출 오버헤드를 피한다

또한 조금 더 효율적으로 최적화가 가능하다

	min(5, 10);
    
    5 < 10 ? 5 : 10; //상수 표현식으로 변환되어 코드를 효율적으로 최적화

이러한 inline함수에도 단점이 존재한다

  • 실행파일 크기 증가
    함수를 호출한 모든곳에 함수 내용이 복사되기 때문에 실팽하일 크기가 커진다 (너무 커지면 메모리 캐시에 담을 수 없어 실행속도가 많이 저하됨)
  • 복잡한 함수에서의 성능 저하
    간단한 함수가 아닌 함수를 inline으로 처리하게 된다면 함수 호출 오버헤드보다 더 큰 코스트가 발생할 수 있다

따라서 간단한 함수이며 단일 함수 호출이 여러번 있는경우 사용하는걸 권장한다

최신 컴파일러의 경우는 자동으로 각 함수와 함수 호출을 평가하여 inline 함수가 효율적일 시 inline expansion을 처리한다

당연하게도 함수의 선언과 정의가 다른 파일에 있는 함수는 inline으로 불가능하다 (컴파일러가 다른 파일에 있는 정의를 볼 수 없기 때문에)

컴파일러가 지금처럼 발전하기 전에 inline 키워드를 명시적으로 작성하여 컴파일러에게 해당 함수를 inline expansion 해달라는 신호로 사용했다

	inline int min(int a, int b)
    {
    	return (a < b) ? a : b;
    }

따라서 modern C++에서는 inline키워드를 위와 같은 성능 목적으로는 잘 사용하지 않게 되었다

  • inline을 잘못 사용하면 오히려 성능 저하가 발생 (컴파일러가 판단하는게 더 정확함)
  • 현대 컴파일러는 자동으로 inline expansion을 처리하기 때문에 명시적으로 inline 요청이 들어와도 처리하지 않을 수 있다
  • inline은 함수 정의부에만 붙힐 수 있다, 컴파일러는 이를 보고 실제로 inline처리를 할지 안할지 결정한다 따라서 프로그래머가 원하는 함수 호출부에서 inline expansion 선택이 불가능하다 (__forceinline으로 강제할 수 있다)

그렇다면 modern C++에서 inline은 어떨때 사용할까?

원래 .h에 함수 정의를 넣게 되면 여러 다른 파일에서 해당 .h를 include할 시 정의가 중복 되어 LNK에러가 발생한다

modern C++에서는 inline 키워드는 여러 정의가 허용된다는 의미로 발전했다, 따라서 inline함수란 다른 파일들에서 여러번 정의될 수 있는 함수이다 (ODR위배 X)

inline함수의 요구사항은 다음과 같다

  • 함수 정의가 .h에 존재하여 다른 파일에서 .h를 #include하여 해당 inline함수의 정의를 볼 수 있어야 한다 (전방선언으로는 안됨)
  • 정의는 전부 동일해야 한다

Linker는 특정 inline 함수의 정의를 단일 정의로 통합한다 (여러번 정의되어도 다 같은 함수임)

	//foo.cpp
    inline double pi() { return 3.14159; }
	
    //main.cpp
    inline double pi() { return 3.14159; }
    
    inline이 아니라면 LNK에러가 발생하지만 inline이기 때문에 두 함수를 단일 정의로 통합하여 에러가 발생하지 않는다 (단 foo.h에 pi() 가 선언되고 main.cpp에 #include "foo.h"를 하고 pi()를 다시 정의하면 에러가 발생한다)

일반적으로 inline함수는 .h에 정의하고 include하여 사용한다

클래스,구조체의 멤버함수와, constexpr함수, 함수 템플릿에서 암묵적으로 인스턴스화 된 함수는 암묵적으로 inline이다

inline함수와 일반 함수의 컴파일 차이

inline함수는 include된 횟수만큼 컴파일된다

예를들어 inline void foo();가 A.cpp, B.cpp, C.cpp 3군데에 include되었다면 총 3번의 함수 컴파일이 발생하는것이다 이때 Linker는 전부 같은 함수라는걸 알기 때문에 (단일 정의 토압) 하나로 병합 후 중복을 제거하는 방식이다

일반 함수와 같은 경우에 .h에 함수를 선언하고 cpp에서 정의한다, 이때 단 한번만 컴파일되고 다른 파일에 전방선언이 되어있어도 컴파일은 단 한번만 된다

또한 inline함수가 수정되면 include된 소스 파일들이 전부 다 recompile 되지만 일반 함수가 수정되면 수정된 해당 소스파일만 recompile된다 (recompile이 연쇄적으로 발생해 빌드 시간이 증가한다)

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글