제 8 장: 정적 데이터와 함수

이전 장에서 실체마다 따로 데이터 멤버 집합을 가지고 있는 클래스의 예를 보았다. 클래스의 멤버 함수는 자신의 실체에 있는 어떤 멤버에도 접근할 수 있었다.

어떤 상황에서는 클래스의 모든 실체가 접근할 수 있도록 공통 데이터 필드를 정의하는 것이 바람직할 경우도 있다. 디렉토리 트리를 재귀적으로 스캔하는 프로그램에 사용되는 시작 디렉토리의 이름이 한 예이다. 초기화가 실제로 일어났는지 알려주는 변수가 또 한 예이다. 이런 경우에 처음 구성된 객체가 초기화를 수행하고 깃발(플래그)을 `done'으로 설정할 것이다.

여러 함수가 같은 변수에 접근할 필요가 있는 그런 상황을 C 언어에서도 만난다. C 언어에서 그 해결책은 보통 이 모든 함수를 하나의 소스 파일에 정의하고 그 변수를 static으로 정의하는 것이다. 변수 이름은 소스 파일 밖에서 보이지 않는다. 이런 접근법은 대체로 유효하지만 소스 파일당 하나의 함수를 사용한다는 원칙에 어긋난다. 또다른 C 언어의 해결책은 해당 변수에 특이한 이름을 붙이는 것이다. 예를 들어 이름을 _6uldv8과 같이 부여하고 프로그램의 다른 부분에서 이 이름이 우연하게라도 사용되지 않기를 바라는 것이다. 첫 번째 방법이나 두 번째의 구형 C 해결책 모두 우아하지 못하다.

C++정적 멤버(static members)를 정의해 이 문제를 해결한다. 클래스의 모든 실체가 데이터와 함수에 접근할 수는 있지만 (비공개 부분에 정의되면) 그 클래스의 밖에서는 접근할 수 없다. 이런 정적 멤버가 이 장에서 다룰 주제이다.

정적 멤버는 가상 함수로 정의할 수 없다. 가상 멤버 함수는 this 포인터를 가지고 있다는 점에서 그냥 보통의 멤버 함수이다. 정적 멤버 함수는 this 포인터가 없기 때문에 가상으로(virtual) 선언할 수 없다.

8.1: 정적 데이터

클래스의 데이터 멤버라면 무엇이든 정적(static)으로 선언할 수 있다. 클래스 인터페이스의 공개(public) 또는 비밀(private) 구역에 배치하면 된다. 정적 데이터 멤버는 한 번만 생성되고 초기화된다. 반면에 비-정적 데이터 멤버는 클래스의 실체마다 따로 생성된다.

정적 데이터 멤버는 프로그램이 시작하자마자 바로 생성된다. 그럼에도 역시 클래스의 멤버이다.

정적 멤버는 앞에 s_를 두어서 데이터 멤버와 쉽게 구별하도록 하는 것이 좋다 (데이터 멤버는 d_로 시작하는 것이 좋다).

공개 정적 데이터 멤버는 전역 변수이다. 프로그램 어디에서든 접근할 수 있다. 클래스 이름과 영역 지정 연산자 그리고 멤버 이름을 주기만 하면 된다. 예를 들어:

    class Test
    {
        static int s_private_int;

        public:
            static int s_public_int;
    };

    int main()
    {
        Test::s_public_int = 145;   // OK
        Test::s_private_int = 12;   // 불가
                                    // 비공개 영역은 건드리지 못함
    }
예제는 실행 파일이 되지 못한다. 그냥 인터페이스를 보여주기 위한 것이지 static 데이터 멤버의 구현을 보여주려는 것이 아니다. 구현은 지금부터 다루어보자.

8.1.1: 비공개 정적 데이터

클래스의 비공개 변수인 정적 데이터 멤버를 사용하는 법을 보여주기 위하여 다음 예제를 연구해 보자:
    class Directory
    {
        static char s_path[];

        public:
            // 생성자, 소멸자, 등등.
    };
s_path[]는 비공개 정적 데이터 멤버이다. 프로그램이 실행되는 동안 Directory::s_path[] 하나만 존재한다. 하지만 Directory 클래스의 실체는 여러 개가 존재할 수 있다. 이 데이터 멤버는 생성자나 소멸자 또는 Directory 클래스의 다른 멤버 함수가 들여다보거나 변경할 수 있다.

생성자는 클래스에서 각 객체가 생성될 때마다 호출되기 때문에 정적 데이터 멤버는 생성자에서 초기화되지 않는다. 기껏해야 변경될 뿐이다. 그 이유는 클래스의 생성자가 호출되기 전에 이미 정적 데이터 멤버가 존재하기 때문이다. 정적 데이터 멤버는 정의될 때나 멤버 함수 밖에서 보통의 (비-클래스) 전역 변수의 초기화와 정확하게 똑같이 초기화된다.

정적 데이터 멤버의 정의와 초기화는 보통 클래스 함수의 소스 파일 중 하나에서 일어나고 정적 데이터 멤버의 정의와 초기화는 보통 그 클래스 함수의 소스 파일 중 한 파일에서 일어난다. 이른바 data.cc라고 부르는 한 파일에서만 전적으로 일어나는게 더 좋다.

위에서 사용된 s_path[] 데이터 멤버는 그리하여 다음과 같이 data.cc 파일에서 정의되고 초기화된다.

    include "directory.ih"

    char Directory::s_path[200] = "/usr/local";
클래스 인터페이스에서 정적 멤버는 실제로 선언만 될 뿐이다. 구현(정의)에 유형과 클래스 이름이 명시적으로 언급된다. 위의 예제에서 보는 바와 같이 인터페이스에서 크기 지정을 생략할 수 있음에도 주목하라. 그렇지만 (명시적이든 묵시적이든) 크기는 정의할 때 필요하다.

이제 어떤 소스 파일에도 클래스의 정적 데이터 멤버를 정의할 수 있다. 따로 data.cc 소스 파일에 두는 것을 권고하지만 main() 함수가 있는 소스 파일에 있어도 된다. 물론 어떤 소스 파일에 클래스의 정적 데이터를 정의하고 있으면 컴파일러에게 알려 주기 위해서 그 클래스의 헤더 파일도 포함할 수 있다.

유용한 비공개 정적 데이터 멤버의 두 번째 예를 아래에 제시한다. 프로그램이 그래픽 장치(VGA 화면)와 통신하는 방식을 Graphics 클래스에 정의해 보자. 장치의 초기화는 이 경우 텍스트 모드에서 그래픽 모드로 전환할 터인데, 이는 생성자가 정적(static) 깃발 변수인 s_nobjects에 의존해 처리한다. s_nobjects 변수는 단순히 한 순간에 존재하는 Graphics 실체의 갯수를 세기만 한다. 비슷하게, 클래스의 소멸자는 마지막 Graphics 실체가 사라지면 다시 그래픽 모드에서 텍스트 모드로 전환한다. 이 Graphics 클래스의 인터페이스는 다음과 같을 것이다.

    class Graphics
    {
        static int s_nobjects;              // 실체의 갯수를 센다.

        public:
            Graphics();
            ~Graphics();                    // 다른 멤버는 보여주지 않음.
        private:
            void setgraphicsmode();         // 그래픽 모드로 전환
            void settextmode();             // 텍스트 모드로 전환
    }
s_nobjects 변수의 목적은 적절한 순간에 존재하는 실체의 갯수를 세는 것이다. 첫 실체가 생성될 때 그래픽 장치가 초기화된다. 마지막 Graphics 실체가 소멸할 때 그래픽 모드에서 텍스트 모드로 전환한다.
    int Graphics::s_nobjects = 0;           // 정적 데이터 멤버

    Graphics::Graphics()
    {
        if (!s_nobjects++)
            setgraphicsmode();
    }

    Graphics::~Graphics()
    {
        if (!--s_nobjects)
            settextmode();
    }
Graphics 클래스에 여러 생성자가 정의되어 있으면 각 생성자마다 s_nobjects 변수를 증가시킬 필요가 있고 그래픽 모드를 초기화해야 할 가능성도 있을 것이다.

8.1.2: 공개 정적 데이터

데이터 멤버는 클래스의 공개 구역에 선언해도 된다. 그렇지만 이것은 데이터 은닉의 원칙에 어긋나기 때문에 별로 추천하지 않는다. 정적 s_path[] 데이터 멤버는 클래스의 공개 구역에 선언할 수 있다 (8.1절). 이렇게 하면 프로그램의 모든 코드가 이 변수에 직접 접근할 수 있다.
    int main()
    {
        getcwd(Directory::s_path, 199);
    }
선언은 정의가 아니다. 결과적으로 s_path 변수는 여전히 정의할 필요가 있다. 이것은 어떤 소스 파일이든 그 안에 s_path[] 배열을 정의할 필요가 있다는 뜻이다.

8.1.3: 정적 상수 데이터 초기화하기

정적 const 데이터 멤버는 다른 정적 데이터와 마찬가지 방식으로 초기화해야 한다. 이 데이터 멤버가 정의된 소스 파일에서 초기화할 필요가 있다.

일반적으로 정적 데이터 멤버들이 정수 유형이거나 내장된 원시 데이터 유형이라면 클래스 안에서 초기화하더라도 컴파일러는 받아들인다. 그렇지만 컴파일러에게 그렇게 하기를 요구하는 공식적인 규정은 없다. 컴파일러가 사용하는 초기화 방식에 따라 컴파일에 성공하기도 하고 실패하기도 한다 (예를 들어, -O2를 사용하면 컴파일에는 성공하더라도 -O0 (최적화-없음) 컴파일에는 실패할 수 있다. 아마도 공유 라이브러리가 사용될 때 그럴 것이다...).

그럼에도 (예를 들어 char, int, long, 등등, 어쩌면 unsigned도 가능) 불변 정수 값의 클래스 내 초기화는 (이름없는) 열거체를 사용할 수 있다. 다음 예제는 그 방법을 보여준다.

    class X
    {
        public:
            enum         { s_x = 34 };
            enum: size_t { s_maxWidth = 100 };
    };

다른 컴파일러 옵션에 의하여 야기되는 혼란을 피하려면 정적 데이터 멤버는 불변(const)이든 아니든 상관없이 언제나 한 소스 파일에서만 명시적으로 정의하고 초기화해야 한다.

8.1.4: 일반화된 상수 표현식 (constexpr)

C는 전처리기에서 간단한 연산을 수행하기 위해 매크로를 자주 사용한다. 매크로 함수는 다음 예제와 같이 인자를 받을 수 있다.
    #define xabs(x) ((x) < 0 ? -(x) : (x))

매크로의 단점은 잘 알려져 있다. 매크로를 피해야 하는 큰 이유는 컴파일러에 의해 해석되는 것이 아니라 전처리기에 의하여 해석이 되므로 결과적으로 단순히 텍스트만 교체될 뿐이며 그리하여 그 자체로 매크로 정의의 구문을 검사한다든지 유형에 안전한지 점검하지 못한다. 게다가 매크로는 전처리기가 처리하므로 그 사용법은 무제한이다. 매크로가 적용되는 문맥을 인지할 필요가 없다. NULL이 악명 높은 한 예이다. enum 심볼을 NULL로 선언하려고 시도해 보셨는가? 아니면 EOF 심볼을 시도해 보셨는지? 그렇게 했다면 컴파일러에게서 괴이한 에러 메시지를 맛보게 될 것이다.

대신에 일반화된 상수 표현식을 사용할 수 있다.

일반화된 상수 표현식은 constexpr 키워드로 식별된다. 이 키워드는 표현식의 유형에 적용된다.

const 키워드의 사용법과 constexpr 키워드의 사용법 사이에 구문적으로 약간 차이가 있다. const 키워드는 선언과 정의에 똑같이 적용이 가능한 반면에 constexpr 키워드는 정의에만 적용할 수 있다.

    extern int const externInt;     // OK: const int 를 정의
    extern int constexpr error;     // ERROR: 정의가 아님

constexpr 키워드로 정의된 변수는 (변경불능의) 상수 값을 가진다. 그러나 일반화된 상수 표현식은 그저 상수 변수를 정의하는 데만 사용되는 것이 아니다. 다른 적용 방법도 있다. constexpr 키워드는 함수에 적용되며 함수를 상수-표현 함수로 바꾼다.

상수-표현 함수를 const 값을 돌려주는 함수와 혼동하면 안된다 (물론 상수-표현 함수는 (상수) 값을 돌려준다). 상수 표현 함수는 다음의 특징이 있다.

이런 함수를 매개변수가 있는 이름 있는 상수 표현식이라고도 부른다.

상수 표현 함수는 컴파일 시간에 평가되는 인자를 가지고 호출될 수도 있다 (꼭 `상수 인자'가 아니어도 된다. const 매개변수 값은 컴파일 시간에 평가되지 않기 때문이다.). 컴파일 시간에 평가되는 인자를 가지고 호출되면 반환 값도 역시 const 값이라고 간주된다.

이렇게 하면 컴파일 시간에 평가할 수 있는 표현식을 함수에 캡슐화해 넣을 수 있다. 그리고 이런 함수들을 이전에 표현식 자체가 사용되어야 하는 상황에 사용할 수 있다. 캡슐화를 사용하면 표현식의 출현 횟수가 하나로 줄어든다. 유지 관리가 단순해지고 에러의 가능성도 낮아진다.

컴파일 시간에 평가가 불가능한 인자를 상수-표현식 함수에 건네면 이런 함수들은 다른 함수와 똑같이 행위한다. 반환 값은 더 이상 상수 표현식으로 간주되지 않는다.

이-차원 배열을 일-차원 배열로 변환해야 한다고 가정해 보자. 일-차원 배열은 원래 배열 자체의 원소의 갯수와 똑같이 nrows * ncols + nrows + ncols + 1개의 원소를 가져야 행과 열 그리고 합계와 총계를 저장할 수 있다. 또 nrowsncols가 전역적으로 사용가능한 size_t const 값으로 정의되어 있다고 가정해 보자 (클래스의 정적 데이터 멤버일 수 있다). 일차원 배열은 클래스 또는 구조체의 데이터 멤버이다. 또는 전역 배열로 정의해도 된다.

이제 필요한 원소의 갯수를 돌려주는 일을 상수-표현식 함수에 캡슐화해 넣을 수 있다.

    size_t const nRows = 45;
    size_t const nCols = 10;

    size_t constexpr nElements(size_t rows, size_t cols)
    {
        return rows * cols + rows + cols + 1;
    }

        ....

    int intLinear[ nElements(nRows, nCols) ];

    struct Linear
    {
        double d_linear[ nElements(nRows, nCols) ];
    };
프로그램의 다른 곳에서 다른 크기의 배열을 위하여 선형 배열을 사용할 필요가 있다면 역시 상수-표현식 함수를 사용할 수 있다. 예를 들어,
    string stringLinear[ nElements(10, 4) ];

상수-표현식 함수는 다른 상수 표현식 함수에도 사용할 수 있다. 다음의 상수-표현식 함수는 nElements가 반환한 값의 절반을 올림하여 돌려준다.

    size_t constexpr halfNElements(size_t rows, size_t cols)
    {
        return (nElements(rows, cols) + 1) >> 1;
    }

클래스는 자신의 데이터 멤버를 외부에 노출시키면 안 된다. 외부 코드와의 결합도를 줄이기 위해서이다. 그러나 클래스에 static const size_t 데이터 멤버가 정의되어 있으면 그의 값을 아주 잘 사용할 수 있다. 배열 원소의 갯수 같이 클래스의 영역 밖에 사는 객체들을 정의하거나 또는 어떤 열거체의 값을 정의하는 데 사용할 수 있다. 이런 상황에 상수-표현식 함수는 데이터 은닉을 적절하게 관리하는 완벽한 도구이다.

    class Data
    {
        static size_t const s_size = 7;

        public:
            static size_t constexpr size();
            size_t constexpr mSize();
    };

    size_t constexpr Data::size()
    {
        return s_size;
    }

    size_t constexpr Data::mSize()
    {
        return size();
    }

    double data[ Data::size() ];        // 좋다. 원소는 7개
    short data2[ Data().mSize() ];      // 역시 좋다. 아래 참고
다음을 눈여겨보라:

C++14 표준은 constexpr 함수의 특징에 제한을 완화했다. C++14 표준에 의하면 constexpr 함수는 다음과 같다.

게다가 C++14는 constexpr 멤버 함수가 상수가 아니어도 허용한다. 그러나 상수-아닌 constexpr 멤버 함수는 constexpr 함수에 지역적으로 정의된 객체의 데이터 멤버만 변경할 수 있다는 것을 유념하라.

8.1.4.1: 상수 표현식 데이터

보시다시피, 원시 데이터 유형의 (멤버) 함수와 변수는 constexpr 키워드로 정의할 수 있다. 클래스 유형의 객체는 어떻게 정의하는가?

클래스의 실체는 클래스 유형의 값이다. 원시 유형의 값처럼 constexpr 키워드로 정의할 수 있다. 클래스 유형의 상수 표현식 실체는 상수 표현식 인자로 초기화해야 한다. 실제로 사용되는 생성자는 그 자체로 constexpr 키워드로 선언되어 있어야 한다. 역시 constexpr 생성자의 정의는 constexpr 실체를 생성하기 전에 컴파일러에게 보여야 한다.

    class ConstExpr
    {
        public:
            constexpr ConstExpr(int x);
    };

    ConstExpr ok(7);            // OK: constexpr로 선언되어 있지 않음

    constexpr ConstExpr err(7); // ERROR:
                                //        생성자의 정의가 아직 안 보인다.

    constexpr ConstExpr::ConstExpr(int x)
    {}

    constexpr ConstExpr ok(7);                  // OK: 정의가 보임
    constexpr ConstExpr okToo = ConstExpr(7);   // 역시 OK

상수-표현식 생성자는 다음과 같은 특징이 있다.

상수-표현식 생성자로 생성된 실체를 사용자-정의 기호상수라고 부른다. 사용자-정의 기호상수의 소멸자와 복사 생성자는 별것이 아니다.

사용자-정의 기호상수의 constexpr 특징은 클래스 멤버가 관리하기도 하고 안 하기도 한다. 멤버가 constexpr 반환 값으로 선언되어 있지 않으면 그 멤버를 호출한 결과는 상수-표현식이 아니다. 멤버가 constexpr 값을 선언하면 그의 반환 값은 그 자체로 상수 표현식 함수일 경우 constexpr로 간주된다. 다음 예제에서 보여주듯이 constexpr 특징을 유지관리하기 위하여 그의 실체가 constexpr 키워드로 정의되어 있을 경우에만 데이터 멤버를 참조할 수 있다.

    class Data
    {
        int d_x;

        public:
            constexpr Data(int x)
            :
                d_x(x)
            {}

            int constexpr cMember()
            {
                return d_x;
            }

            int member() const
            {
                return d_x;
            }
    };

    Data d1(0);             // OK, 그러나 상수 표현식이 아니다.

    enum e1 {
        ERR = d1.cMember()  // ERROR: cMember(): 더 이상
    };                      //        상수 표현식이 아니다.

    constexpr Data d2(0);   // OK, 상수 표현식

    enum e2 {
        OK = d2.cMember(),   // OK: cMember(): 이제
                             //                상수 표현식이다.
        ERR = d2.member(),   // ERR: member(): 
    };                       //                상수 표현식이 아니다.

8.2: 정적 멤버 함수

정적 데이터 멤버 외에도 C++정적 멤버 함수를 정의할 수 있다. 클래스의 모든 실체가 공유하는 정적 데이터와 마찬가지로 정적 멤버 함수도 역시 클래스에 연관된 실체가 없어도 존재한다.

정적 멤버 함수는 클래스의 모든 정적 멤버에 접근할 수 있을 뿐만 아니라 클래스 실체의 일반 멤버에도 접근할 수 있다 (private 또는 public). 이런 실체들이 있다고 알려주기만 하면 된다 (앞으로 나올 예제에서 보여줌). 정적 멤버 함수는 클래스의 실체와 관련이 없다. 그래서 this 포인터가 없다. 사실, 정적 멤버 함수는 거의 전역 함수나 다름없다. 즉, 클래스와 전혀 연관이 없다 (즉, 사실상 전역 함수이다. 미묘한 차이는 다음 8.2.1항). 정적 멤버 함수는 연관 실체를 요구하지 않으므로 클래스 인터페이스의 공개 구역에 선언된 정적 멤버 함수는 해당 클래스의 실체를 지정하지 않아도 호출이 가능하다. 다음 예제는 정적 멤버 함수의 이 특징을 보여준다.

    class Directory
    {
        string d_currentPath;
        static char s_path[];

        public:
            static void setpath(char const *newpath);
            static void preset(Directory &dir, char const *newpath);
    };
    inline void Directory::preset(Directory &dir, char const *newpath)
    {
                                                    // 아래 텍스트 참조
        dir.d_currentPath = newpath;                // 1
    }

    char Directory::s_path[200] = "/usr/local";     // 2

    void Directory::setpath(char const *newpath)
    {
        if (strlen(newpath) >= 200)
            throw "newpath too long";

        strcpy(s_path, newpath);                    // 3
    }

    int main()
    {
        Directory dir;

        Directory::setpath("/etc");                 // 4
        dir.setpath("/etc");                        // 5

        Directory::preset(dir, "/usr/local/bin");   // 6
        dir.preset(dir, "/usr/local/bin");          // 7
    }

예제에서는 공개 정적 멤버 함수만 사용되었다. C++는 또한 비밀 멤버 함수도 정적으로 정의할 수 있다. 해당 클래스의 멤버 함수만 정적 비밀 멤버 함수를 호출할 수 있다.

8.2.1: 호출 관례

앞 항에서 지적했듯이 정적 (공개) 멤버 함수는 클래스 없는 함수나 다름없다. 그렇지만 공식적으로 이 서술은 올바르지 않다. C++ 표준은 정적 멤버 함수를 호출하는 관례를 (클래스 없는) 전역 함수와 다르게 취급하기 때문이다.

실제로 호출 관례는 동일하다. 함수에 건넬 매개변수가 (전역) 함수를 가리키는 포인터라면 그 인자로 정적 멤버 함수의 주소를 사용할 수 있다는 뜻이다.

예상치 못한 놀라움을 피하고 싶다면 다른 함수를 역호출할 함수인 정적 멤버 함수에 (클래스 없는) 전역 포장 함수를 두르기를 제안한다.

전통적으로 C에서 역호출 함수가 사용되는 상황이라면 C++는 템플릿 알고리즘을 사용한다는 사실을 깨닫자 (제 19장).

Person 클래스가 있다고 간주하자. 데이터 멤버는 개인의 이름과 주소 그리고 전화번호와 몸무게를 나타낸다. 그리고 이 포인터들이 가리키는 Person 객체들을 비교하여 Person 실체를 가리키는 포인터 배열을 정렬하고 싶다고 가정해 보자. 일을 쉽게 하기 위해 다음의 공개 정적 멤버 함수가 존재한다고 간주한다.

    int Person::compare(Person const *const *p1, Person const *const *p2);
이 멤버의 유용한 특징은 멤버 함수에 건네어진 두 Person 실체에 필요한 데이터 멤버를 직접적으로 포인터의 포인터를 사용하여 조사할 수 있다는 것이다 (이중 포인터).

대부분의 컴파일러에서 표준 C qsort() 함수에 대하여 비교 함수의 주소로 이 함수의 주소를 건넬 수 있다. 예를 들어,

    qsort
    (
        personArray, nPersons, sizeof(Person *),
        reinterpret_cast<int(*)(void const *, void const *)>(Person::compare)
    );
그렇지만 정적 멤버와 클래스 없는 함수에 대하여 컴파일러가 서로 다른 호출 관례를 사용하면 이 방식은 작동하지 않을 가능성이 있다. 그런 경우에 다음과 같이 클래스 없는 함수를 사용하면 목적을 이룰 수 있다.
    int compareWrapper(void const *p1, void const *p2)
    {
        return
            Person::compare
            (
                static_cast<Person const *const *>(p1),
                static_cast<Person const *const *>(p2)
            );
    }
결과적으로 다음과 같이 qsort() 함수를 호출한다.
    qsort(personArray, nPersons, sizeof(Person *), compareWrapper);
요약: