본문 바로가기
프로그래밍/C & C++

[C++] static 키워드 (+ extern)

by 별준 2022. 2. 18.

References

Contents

  • static 키워드 용도
  • extern 키워드
  • non-local 변수의 초기화(소멸) 순서

C++ 코드에서 static 키워드의 용도는 다양하며, 얼핏보면 그 용도들간에는 전혀 관련이 없어 보입니다. 이렇게 키워드를 다양한 곳에서 사용하도록 '오버로딩'한 것은 C++언어에서 키워드가 늘어나는 것을 피하기 위해서 입니다.

 

이번 포스팅에서는 다양한 static 키워드의 용도에 대해서 알아보겠습니다.

 


static Data Member and Methods

먼저 클래스의 데이터 멤버와 메소드를 static으로 선언할 수 있습니다. static으로 선언한 데이터 멤버는 non-static 데이터 멤버와 달리 객체에 속하지 않습니다. 다시 말해 static 데이터 멤버는 객체 외부에 단 하나만 존재합니다.

 

static 메소드도 객체가 아닌 클래스에 속한다는 점은 같습니다. static 메소드는 특정 객체를 통해서 실행되지 않습니다. 따라서 static 메소드 내에서는 암시적인 this 포인터를 가지지 않습니다. 이러한 이유로 static 메소드에는 const도 지정될 수 없습니다.

 

아래의 포스팅에서 static 데이터 멤버와 메소드에 대해서 다룬 적이 있으니, 필요하시다면 참고바랍니다 !

[C++] 클래스(Class) 심화편 (2)

 

[C++] 클래스(Class) 심화편 (2)

References Professional C++ https://en.cppreference.com/w/ Contents 메소드의 종류 (static, const, overloading, inline) 데이터 멤버의 종류 중첩 클래스, 클래스 내부의 열거 타입 클래스 심화편 두 번째..

junstar92.tistory.com

 


static Linkage

static 링크에 대해 알아보기 전에 먼저 C++에서 링크를 처리하는 과정을 이해할 필요가 있습니다.

C++은 콛를 소스 파일 단위로 컴파일해서 그 결과로 나온 오브젝트 파일(object files)들을 링크 단계에서 서로 연결합니다. 함수나 전역 변수를 포함하는 C++ 소스 파일마다 정의된 이름은 외부(external) 링크나 내부(internal) 링크를 통해 서로 연결됩니다. 외부 링크(external linkage)는 다른 소스파일에서 해당 이름을 사용할 수 있다는 것을 의미하고, 내부 링크(internal linkage)는 같은 파일에서만 사용할 수 있습니다. 기본적으로 함수나 전역 변수는 외부 링크가 적용됩니다. 하지만 선언문 앞에 static 키워드를 붙이면 내부 링크(or static linkage)가 적용됩니다. 

 

예를 들어, FirstFile.cpp와 AnotherFile.cpp란 소스 파일이 있다고 가정해봅시다.

FirstFile.cpp는 다음과 같습니다.

/* FirstFile.cpp */
void f();

int main()
{
    f();
}

이 파일은 f()의 프로토타입만 있고 이를 정의하는 코드는 없습니다.

 

AnotherFile.cpp는 다음과 같습니다.

/* AnotherFile.cpp */
#include <iostream>

void f();

void f()
{
    std::cout << "f\n";
}

이 파일은 f()를 선언하는 코드와 정의하는 코드가 모두 있습니다. 이렇게 같은 함수에 대한 프로토타입을 여러 파일에 작성해도 됩니다. 이것이 바로 전처리기가 하는 일인데, 전처리기는 소스 파일에 있는 #include 파일을 보고 헤더 파일에 나온 프로토타입을 소스 파일에 추가합니다. 헤더 파일을 사용하는 이유는 여러 파일에서 일관성 있게 프로토타입을 유지하기 위해서 입니다.

 

위의 소스 파일들은 모두 아무런 에러없이 컴파일되고 링크됩니다. f()가 외부 링크로 처리되어 main()에서 다른 파일에 있는 함수를 호출할 수 있기 때문입니다.

 

이번에는 f()의 프로토타입에 static을 붙여보도록 하겠습니다. 참고로 f()를 정의하는 코드에서는 static 키워드를 생략해도 됩니다. 단, 함수 프로토타입이 정의하는 코드보다 앞에 나와야 합니다.

/* AnotherFile.cpp */
#include <iostream>

static void f();

void f()
{
    std::cout << "f\n";
}

이렇게 수정해도 컴파일 과정에서는 아무런 에러가 발생하지 않지만 링크 단계에서 에러가 발생합니다. f()를 static으로 지정해서 내부 링크로 변경되어 FirstFile.cpp에서 찾을 수 없기 때문입니다. 어떤 컴파일러는 이렇게 static으로 정의했지만 실제로 소스 파일에서 사용하지 않으면 경고 메세지를 출력합니다. 정의 코드만 있고 사용하지 않는다면 다른 파일에서 사용할 수 있다는 것을 의미하기 때문입니다.

 

static 대신 익명 네임스페이스(anonmous namespace)를 이용하여 내부 링크가 적용되게 할 수도 있습니다. 예를 들어 다음과 같이 변수나 함수를 static으로 지정하지 말고 이름이 없는 네임스페이스로 감싸면 됩니다.

/* AnotherFile.cpp */
#include <iostream>

namespace {
    void f();

    void f()
    {
        std::cout << "f\n";
    }
}

이름이 없는 네임스페이스에 속한 항목은 이를 선언한 소스 파일 안에서는 얼마든지 접근할 수 있지만 다른 소스 파일에서는 접근할 수 없습니다. 그래서 static 키워드로 선언할 때와 효과가 같습니다.

 

extern 키워드

static 키워드와 관련된 키워드로 extern 키워드가 있습니다. 이름만 보면 static과 정반대로 외부 링크를 지정할 때 사용하는 것처럼 보이는데 실제로 이렇게 사용하기도 합니다. 예를 들어 const와 typedef는 기본적으로 내부 링크로 처리됩니다. 여기서 extern을 붙이면 외부 링크가 적용됩니다. 하지만 extern은 조금 복잡합니다. 어떤 이름을 extern으로 지정하면 컴파일러는 이를 정의가 아닌 선언문으로 처리합니다. 변수를 extern으로 지정하면 컴파일러는 그 변수에 대해 메모리를 할당하지 않습니다. 따라서 그 변수를 정의하는 문장을 따로 작성해야 합니다.

예를 들어, 아래의 AnotherFile.cpp 코드를 살펴보겠습니다.

/* AnotherFile.cpp */
extern int x;
int x{ 3 };

또는 extern으로 선언하는 동시에 초기화를 해도 됩니다. 그러면 선언과 정의를 한 문장에서 처리할 수 있습니다.

/* AnotherFile.cpp */
extern int x = { 3 };

이 경우에는 굳이 extern이란 키워드를 적을 필요가 없습니다. extern을 붙이지 않아도 x는 기본적으로 외부 링크로 처리되기 때문입니다.

 

extern이 반드시 필요한 경우는 다음과 같이 FirstFile.cpp와 같은 다른 소스 파일에서 x에 접근하게 만들 때입니다.

/* FirstFile.cpp */
#include <iostream>

extern int x;

int main()
{
    std::cout << x << std::endl;
}

FirstFile.cpp에서 x를 extern으로 선언했기 때문에 다른 파일에 있던 x를 여기서 사용할 수 있는 것입니다. main()에서 x를 사용하려면 컴파일러가 x의 선언문을 알아야 합니다. x를 선언하는 문장에 extern을 붙이지 않으면 이 문장이 x를 선언하는 것이 아니라 정의한다고 판단해서 메모리를 할당해버립니다. 그러면 전역 스코프에 x라는 변수가 2개가 만들어지기 때문에 링크 단계에서 에러가 발생합니다. 이럴 때 extern을 붙이면 다른 소스 파일에서 전역적으로 접근할 수 있는 변수로 만들게 됩니다.

전역 변수는 최대한 사용하지 않는 것이 좋습니다. 코드가 커질수록 이해하기 힘든 에러가 발생하기 때문입니다.

 


static Variables in Functions

함수 안에서 static으로 지정한 변수는 그 함수만 접근할 수 있는 전역 변수와 같습니다. 주로 어떤 함수에서 초기화 작업 수행 여부를 기억하는 용도로 많이 사용합니다.

예를 들면 다음과 같습니다.

void performTask()
{
    static bool initialized = false;
    if (!initialized) {
        std::cout << "initializing" << std::endl;
        // perform initalization
        initialized = true;
    }
    // perform the desired task
}

그런데 이렇게 static 변수를 사용하면 헷갈리기 쉬우며, static 변수를 정의할 필요가 없도록 코드 구조를 변경하는 것이 바람직합니다. 이 예제의 경우에는 특수한 초기화 작업을 수행하는 생성자를 클래스에 별도로 정의할 수 있습니다.

static 변수를 따로 만들어서 상태를 관리하지 말고, 상태를 객체 안에서 관리하도록 만드는 것이 좋습니다.

 

하지만 이렇게 static 변수를 사용할 때가 좋은데, 대표적인 예로 싱글톤(singleton) 디자인 패턴이 있습니다. 이 디자인 패턴에 관해서는 나중에 다른 포스트로 다루어 볼 예정입니다.

 

위의 예제에서 사용한 performTask()는 스레드에 안전하지 않으며, 경쟁 상태(race condition)이 발생할 수 있습니다. 멀티스레드 환경에서 실행하려면 atomic과 같은 스레드 동기화 메커니즘을 적용해야 합니다.

 


Nonlocal 변수의 초기화 순서

이번에는 static 데이터 변수와 전역 변수의 초기화 순서에 대해서 살펴보겠습니다. 프로그램에서 모든 전역 변수와 static 데이터 멤버는 모두 main()이 시작되기 전에 초기화됩니다. 이러한 변수는 소스 파일에 선언된 순서대로 초기화됩니다.

예를 들어 다음과 같이 작성했다면, Demo::x는 y보다 먼저 초기화됩니다.

class Demo
{
public:
    static int x;
};
int Demo::x{ 3 };
int y{ 4 };

그런데 C++ 표준은 다른 소스 파일들에서 선언된 nonlocal 변수에 대한 초기화 순서는 따로 정해두지 않았습니다. 어떤 소스 파일에 x란 전역 변수가 있고, 다른 파일에는 y라는 전역 변수가 있을 때 어느 것이 먼저 초기화되는지 알 수 없습니다. 어느 것이 먼저 초기화되든 상관없는 경우가 대부분이지만 간혹 전역 변수나 static 변수가 의존 관계에 있을 때는 문제가 발생할 수 있습니다. 앞에서 설명했듯이 객체를 초기화한다는 말은 생성자가 실행된다는 것을 의미합니다. 어떤 전역 객체의 생성자 안에서 다른 전역 객체에 접근할 수 있는데, 이렇게 하기 위해서는 접근할 객체가 이미 생성되어 있어야 합니다. 두 소스 파일에 정의된 전역 객체 중 어느 것이 먼저 생성될지 알 수 없고, 초기화되는 순서도 제어할 수 없습니다. 게다가 컴파일러의 종류마다, 심지어 같은 컴파일러라도 버전에 따라서 순서가 바뀔 수 있습니다. 또한 프로젝트에 다른 파일을 추가하면 순서가 바뀌기도 합니다.

 


Nonlocal 변수의 소멸 순서

nonlocal 변수는 생성된 순서와 반대로 소멸됩니다. 하지만, 여러 소스 파일에 있는 nonlocal 변수의 초기화 순서는 정의되어 있지 않기 때문에 소멸되는 순서도 알 수 없습니다.

 

 

초기화와 관련되어 이전 포스팅에서 한 번 비슷한 주제를 다룬 적이 있습니다. 혹시 관심있다면 참고하셔도 좋을 것 같습니다 !

[C++] 객체 초기화 / 비지역 정적 객체의 초기화

 

[C++] 객체 초기화 / 비지역 정적 객체의 초기화

Reference Effective C++ (항목 4) Contents 객체 초기화 멤버 초기화 리스트 비지역 정적 객체의 초기화 순서 int x; class Point { int x, y; }; Point p; C++에서 위의 코드처럼 객체를 선언할 때, 어떤 상황에..

junstar92.tistory.com

 

댓글