제 16 장: 멤버를 가리키는 포인터가 있는 클래스

포인터 데이터 멤버가 있는 클래스를 제 9장에 자세하게 다루었다. 이런 클래스는 특별히 주의를 기울일 필요가 있다. 복사 연산자와 중복정의 할당 연산자 그리고 소멸자를 정의해 주어야 하기 때문이다.

실체를 가리키는 포인터 말고 클래스의 멤버를 가리키는 포인터가 필요한 상황이 있다. 멤버를 가리키는 포인터는 클래스 실체의 행위를 재단하는데 유용하게 사용할 수 있다. 포인터가 어떤 멤버를 가리키는가에 따라 객체마다 다른 행위를 보여줄 것이다.

멤버를 가리키는 포인터는 나름의 사용법이 있지만 비슷한 행위를 실현하기 위해 다형성이 더 자주 사용된다. process라는 멤버가 있는 클래스를 생각해 보자. 이 멤버는 일련의 대안 행위 중에 하나를 수행한다. 실체 생성 시간에 행위를 선택하는 대신에 클래스는 (추상) 바탕 클래스의 인터페이스를 사용할 수 있다. 파생 클래스의 실체를 그의 생성자에게 건네고 그리하여 그의 행위를 구성할 수 있다. 이렇게 하면 유연하고 확장성 있는 환경을 손쉽게 구성할 수 있다. 그러나 클래스 데이터 멤버에 접근하는 것은 유연성이 떨어지며 `friend' 선언을 사용할 필요성이 있을 수 있다. 그런 경우 멤버를 가리키는 포인터가 실제로 더 좋을 수 있다. (약간 유연성은 떨어지지만) 클래스의 데이터 멤버에 직접 접근할 수 있고 쉽게 환경을 구성할 수 있기 때문이다.

그래서 클래스의 데이터 멤버에 쉽게 접근할 것인가 아니면 쉽게 환경을 구성할 것인가에 따라 선택하면 된다. 이 장은 멤버를 가리키는 포인터에 집중해서 이 포인터들이 무엇을 제공해야 하는지 조사해 본다.

16.1: 멤버를 가리키는 포인터: 예제

변수와 객체를 포인터로 어떻게 사용하는지 안다고 하더라도 직관적으로 멤버를 가리키는 포인터라는 개념에 이르는 것은 아니다. 멤버 함수의 반환 유형과 매개변수 유형을 알고 있더라도 쉽게 놀라움을 맞이할 수 있다. 예를 들어 다음 클래스를 연구해 보자:
    class String
    {
        char const *(*d_sp)() const;

        public:
            char const *get() const;
    };
이 클래스에 대하여 char const *(*d_sp)() constString::get 멤버 함수를 가리키도록 만드는 것은 불가능하다. d_spget 멤버 함수의 주소를 줄 수 없기 때문이다.

그 한 가지 이유는 d_sp 변수는 전역 영역에 있는데 반해, get 멤버 함수는 String 클래스 안에 정의되어 있고 그리하여 클래스 영역에 있기 때문이다 (전역 함수를 가리키는 포인터이지 String 안에 있는 함수를 가리키는 포인터가 아니다). d_sp 변수가 String 클래스의 데이터 멤버라는 사실은 여기에 전혀 관련이 없다. d_sp의 정의에 따르면 클래스 밖 어디엔가 존재하는 함수를 가리킨다.

결론적으로 클래스의 멤버를 (즉, 클래스의 데이터나 함수를가리키는 그러나 보통은 클래스의 함수를) 포인터로 정의하기 위해 포인터의 영역은 반드시 클래스 영역을 나타내야 한다. 그리하여 String::get 멤버를 가리키는 포인터는 다음과 같이 정의된다.

    char const *(String::*d_sp)() const;
그래서 *d_sp 포인터 데이터 멤버 앞에 String::을 배치함으로써 String 클래스 문맥 안에서 포인터로 정의된다. 그의 정의에 따라 String 클래스 안의 함수를 가리키는 포인터이지만 인자를 기대하지 않으며 자신의 객체 데이터를 변경하지 않고 그리고 불변 문자를 포인터로 돌려준다.

16.2: 멤버를 가리키는 포인터 정의하기

멤버를 가리키는 포인터는 보통의 포인터 표기법 앞에 적절한 클래스와 영역 결정 연산자를 배치하여 정의한다. 그러므로 앞 절에서 char const * (String::*d_sp)() const를 사용해 d_sp가 다음과 같다는 사실을 나타냈다.

그러므로 부합하는 함수의 원형은 다음과 같다.

    char const *String::somefun() const;
String 클래스 안의 const 함수로서 매개변수가 없고 char const *를 돌려준다.

멤버를 포인터로 정의할 때 함수를 가리키는 포인터를 생성하기 위한 표준 절차를 똑같이 적용할 수 있다.

다음 예제도 데이터 멤버를 가리키는 포인터를 보여준다. String 클래스에 string d_text 멤버가 있다고 간주하자. 이 멤버를 가리키는 포인터를 어떻게 생성할 것인가? 또다시 표준 절차를 따르면 된다.

대안으로, 지켜야 할 아주 간단한 규칙은 다음과 같다.

예를 들어 전역 함수를 가리키는 다음 포인터는
    char const * (*sp)() const;
클래스 영역을 앞에 붙이면 멤버 함수를 가리키는 포인터가 된다.
    char const * (String::*sp)() const;
멤버를 가리키는 포인터를 목표 클래스 (String) 안에 반드시 정의해야 하는 것은 아니다. 멤버를 가리키는 포인터는 목표 클래스나 (그래서 데이터 멤버가 된다) 또다른 클래스에 또는 지역 변수나 전역 변수로 정의할 수 있다. 이 모든 상황에 멤버 변수를 가리키는 포인터에 포인터가 가리키는 멤버의 주소를 줄 수 있다. 요점은 목표 클래스의 실체가 없어도 멤버를 가리키는 포인터를 초기화하거나 할당할 수 있다는 것이다.

그런 포인터에 대하여 주소를 할당하거나 초기화하는 것은 그저 그 포인터가 어느 멤버를 가리키는지 나타낼 뿐이다. 이것을 함수가 호출되는 실체에 상대적인 일종의 상대 주소로 간주해도 좋다. 멤버를 가리키는 포인터를 초기화하거나 할당할 때 실체가 전혀 필요없다. 멤버를 가리키는 포인터를 할당하거나 초기화해도 허용한다. 그렇지만 (물론) 올바른 유형의 실체를 지정하지 않고서 그런 멤버를 호출할 수는 없다.

다음 예제에서 멤버를 가리키는 포인터의 초기화와 할당 방법을 보여준다 (예시를 위해 PointerDemo 클래스의 멤버는 모조리 public으로 정의했다). 예제에서는 멤버의 주소를 결정하기 위해 & 연산자가 사용되었다. 이런 연산자와 더불어 클래스 영역이 요구된다. 멤버 구현 안에 사용되었음에도 불구하고 말이다.

    #include <cstddef>

    class PointerDemo
    {
        public:
            size_t d_value;
            size_t get() const;
    };

    inline size_t PointerDemo::get() const
    {
        return d_value;
    }

    int main()
    {                                           // 초기화
        size_t (PointerDemo::*getPtr)() const = &PointerDemo::get;
        size_t PointerDemo::*valuePtr         = &PointerDemo::d_value;

        getPtr   = &PointerDemo::get;           // 할당
        valuePtr = &PointerDemo::d_value;
    }

별로 특별한 것은 없다. 전역 포인터와의 차이는 이제 영역이 PointerDemo 클래스 안으로 제한된다는 것이다. 이 제한 때문에 주소에 사용되는 모든 포인터 정의와 모든 변수는 PointerDemo 클래스 영역이 주어져야 한다.

멤버를 가리키는 포인터는 또 가상(virtual) 멤버 함수와 함께 사용할 수 있다. 가상 멤버를 가리킬 때 특별한 구문이 요구되지 않는다. 포인터 생성과 초기화 그리고 할당은 비-가상 멤버와 똑 같은 방식으로 수행된다.

16.3: 멤버를 가리키는 포인터 사용하기

포인터로 멤버 함수를 호출하려면 클래스 실체가 있어야 된다. 포인터는 전역 영역에서 작동하므로 역참조 연산자 *가 사용된다. 포인터 선택자 (->) 또는 객체 선택자 (.)를 사용하면 실체를 가리키는 포인터로 적절한 멤버를 선택할 수 있다.

멤버를 가리키는 포인터를 실체와 함께 조합하여 사용하려면 .* 필드 선택자를 지정해야 한다. 실체를 가리키는 포인터를 통하여 멤버를 사용하려면 ->* 필드 선택자를 지정해야 한다. 이 두 연산자는 필드 선택자의 표기법(. 그리고->)을 조합하여 역참조 객체에 있는 적절한 필드에 도달한다. 포인터가 가리키는 멤버 변수나 멤버 함수에 도달하기 위하여 역참조 연산을 사용한다.

이전 절의 예제를 이용하여 멤버 함수와 데이터 멤버를 포인터로 어떻게 사용하는지 살펴보자:

    #include <iostream>

    class PointerDemo
    {
        public:
            size_t d_value;
            size_t get() const;
    };

    inline size_t PointerDemo::get() const
    {
        return d_value;
    }

    using namespace std;

    int main()
    {                                           // 초기화
        size_t (PointerDemo::*getPtr)() const = &PointerDemo::get;
        size_t PointerDemo::*valuePtr   = &PointerDemo::d_value;

        PointerDemo object;                     // (1) (본문 참조)
        PointerDemo *ptr = &object;

        object.*valuePtr = 12345;               // (2)
        cout << object.*valuePtr << '\n' <<
                object.d_value << '\n';

        ptr->*valuePtr = 54321;                 // (3)
        cout << object.d_value << '\n' <<
                (object.*getPtr)() << '\n' <<   // (4)
                (ptr->*getPtr)() << '\n';
    }

    /* 
        출력:

        12345
        12345
        54321
        54321
        54321
    */

다음을 눈여겨보라:

멤버를 가리키는 포인터는 환경 설정에 따라 다르게 행위하는 멤버가 클래스에 있을 때 유용하게 사용할 수 있다. 이전 9.3절Person 클래스를 다시 한 번 연구해 보자. Person에는 이름과 주소 전화번호를 보유한 데이터 멤버가 정의되어 있다. Person 사원 데이터베이스를 구성하고 싶다고 해보자. 사원 데이터베이스에 질의할 수는 있지만, 사원의 형태에 따라 데이터베이스에 질의할 때 이름과 전화 번호 또는 저장된 모든 개인 정보를 볼 수 있다. address 같은 멤버 함수는 열람자가 개인의 주소를 보도록 허용되어 있지 않을 경우에 `<열람 불가>' 메시지를 돌려주고 그렇지 않으면 실제 주소를 돌려 주어야 한다는 뜻이다.

사원 데이터베이스는 질의하고자 하는 사원의 직위를 반영한 인자를 지정해 연다. 직위는 예를 들어 BOARD, SUPERVISOR, SALESPERSON, 또는 CLERK와 같이 조직에서의 계급을 반영할 수 있다. 앞의 두 범주는 사원에 관하여 모든 정보를 볼 수 있고 SALESPERSON은 사원의 전화 번호를 볼 수 있는 반면에 CLERK는 사원인지 아닌지만 볼 수 있다.

이제 데이터베이스 클래스에 string personInfo(char const *name) 멤버를 생성한다. 이 클래스의 표준 구현은 다음과 같을 것이다.

    string PersonData::personInfo(char const *name)
    {
        Person *p = lookup(name);   // `name'이 존재하는지 확인한다.

        if (!p)
            return "not found";

        switch (d_category)
        {
            case BOARD:
            case SUPERVISOR:
                return allInfo(p);
            case SALESPERSON:
                return noPhone(p);
            case CLERK:
                return nameOnly(p);
        }
    }
크게 시간이 걸리는 것은 아니지만 그럼에도 switchpersonInfo가 호출될 때마다 평가되어야 한다. switch를 사용하는 대신에 d_infoPtr 멤버를 PersonData의 멤버 함수를 가리키는 포인터로 정의할 수 있다. string 문자열을 돌려주고 Person을 가리키는 포인터를 그의 인자로 기대한다.

switch를 평가하는 대신에 이 포인터를 사용하여 allInfonoPhone 또는 nameOnly를 가리킬 수 있다. 그리고 포인터가 가리키는 멤버 함수는 PersonData 객체가 생성될 쯤이면 알려진다. 그래서 그의 값은 (PersonData 객체가 생성될 때) 한 번만 결정하면 된다.

d_infoPtr를 초기화했기 때문에 personInfo 멤버 함수는 이제 간단하게 다음과 같이 구현된다.

    string PersonData::personInfo(char const *name)
    {
        Person *p = lookup(name);       // `name'이 존재하는지 확인한다.

        return p ? (this->*d_infoPtr)(p) :  "not found";
    }

d_infoPtr 멤버는 다음과 같이 정의된다 (PersonData 클래스 안에 정의되고 다른 멤버는 모두 생략함):

    class PersonData
    {
        string (PersonData::*d_infoPtr)(Person *p);
    };
마지막으로, 생성자는 d_infoPtr를 초기화한다. switch 구문을 사용하여 간단하게 구현할 수 있다.
    PersonData::PersonData(PersonData::EmployeeCategory cat)
    :
        switch (cat)
        {
            case BOARD:
            case SUPERVISOR:
                d_infoPtr = &PersonData::allInfo;
            break;
            case SALESPERSON:
                d_infoPtr = &PersonData::noPhone;
            break;
            case CLERK:
                d_infoPtr = &PersonData::nameOnly;
            break;
        }
    }
멤버 함수의 주소가 어떻게 결정되는지 눈여겨보라. 이미 PersonData 클래스의 멤버 함수 안에 있음에도 불구하고 PersonData 클래스의 영역을 지정해야 한다.

stable_sort 총칭 알고리즘의 문맥에서 데이터 멤버를 가리키는 포인터의 사용법은 19.1.60항에 있다.

16.4: 정적 멤버를 가리키는 포인터

클래스의 공개 정적 멤버는 실체가 없어도 마치 자유 함수처럼 호출할 수 있다. 물론 호출하려면 클래스 이름을 명시해야 한다.

String 클래스에 공개 정적 count 멤버 함수가 있어서 지금까지 만든 문자열 객체의 갯수를 돌려준다고 해 보자. 그러면 String 실체가 전혀 없어도 String::count 함수를 호출할 수 있다.

    void fun()
    {
        cout << String::count() << '\n';
    }
공개 정적 멤버는 자유 함수처럼 호출이 가능하다 (그러나 8.2.1항 참고). 비밀 정적 멤버는 오직 클래스 문맥 안에서만 호출할 수 있다. 클래스 멤버나 친구 함수로만 호출이 가능하다.

정적 멤버는 연관된 실체가 없기 때문에 주소를 평범한 함수 포인터 변수에 저장할 수 있으므로 전역 수준에서 작동한다. 멤버를 가리키는 포인터는 정적 멤버의 주소를 저장하지 못한다. 예를 들어:

    void fun()
    {
        size_t (*pf)() = String::count;
                // 정적 멤버 함수의 주소로 pf를 초기화

        cout << (*pf)() << '\n';
                // String::count() 멤버가 돌려준 값을 보여줌
    }

16.5: 멤버를 가리키는 포인터의 크기

멤버를 가리키는 포인터는 흥미로운 특징이 있다. `보통의' 포인터와 크기와 다르다. 다음의 작은 프로그램을 연구해 보자:
    #include <string>
    #include <iostream>

    class X
    {
        public:
            void fun();
            std::string d_str;
    };
    inline void X::fun()
    {
        std::cout << "hello\n";
    }

    using namespace std;
    int main()
    {
        cout <<
           "size of pointer to data-member:     " << sizeof(&X::d_str) << "\n"
           "size of pointer to member function: " << sizeof(&X::fun) << "\n"
           "size of pointer to non-member data: " << sizeof(char *) << "\n"
           "size of pointer to free function:   " << sizeof(&printf) << '\n';
    }

    /*
        (32-비트 골격구조에서의) 출력:

        size of pointer to data-member:     4
        size of pointer to member function: 8
        size of pointer to non-member data: 4
        size of pointer to free function:   4
    */

32-비트 골격구조에서 멤버 함수를 가리키는 포인터는 8 바이트를 요구한다. 반면에 다른 종류의 포인터는 4 바이트를 요구한다 (Gnu g++ 컴파일러 사용).

포인터 크기가 명시적으로 사용되는 경우는 거의 없지만 크기 때문에 다음과 같은 서술문에서 혼란을 일으킬 가능성이 있다.

    printf("%p", &X::fun);
물론 printf는 이렇게 C++에 종속적인 포인터의 값을 출력하기에 올바른 도구는 아니다. 공용체(union)를 사용하여 8-바이트 크기의 포인터를 일련의 size_t char 값으로 재해석하면 이 포인터의 값을 스트림에 삽입할 수 있다.
    #include <string>
    #include <iostream>
    #include <iomanip>

    class X
    {
        public:
            void fun();
            std::string d_str;
    };
    inline void X::fun()
    {
        std::cout << "hello\n";
    }

    using namespace std;
    int main()
    {
        union
        {
            void (X::*f)();
            unsigned char *cp;
        }
            u = { &X::fun };

        cout.fill('0');
        cout << hex;
        for (unsigned idx = sizeof(void (X::*)()); idx-- > 0; )
            cout << setw(2) << static_cast<unsigned>(u.cp[idx]);
        cout << '\n';
    }
    /*
        출력:
        401057bef87d894810ec8348e5894855
    */

그러나 왜 평범한 포인터와 크기가 다른가? 이 질문에 대답하기 위하여 먼저 익숙한 std::fstream을 살펴 보자. std::ifstreamstd::ofstream으로부터 파생되었으므로 fstream에는 ifstreamofstream이 모두 들어있다. fstream그림 21과 같이 조직해 보겠다.

그림 21: std::fstream 객체 조직도

fstream (a)에서 첫번째 바탕 클래스는 std::istream이고 두 번째 바탕 클래스는 std::ofstream이다. 그러나 순서를 뒤바꾸는 것도 역시 문제가 없다. fstream (b)에 보여주는 바와 같이 말이다. 먼저 std::ofstream을 다음에 std::ifstream을 두어도 된다. 그것이 바로 핵심이다.

fstream fstr{"myfile"} 객체가 있고 fstr.seekg(0)를 실행하면 ifstreamseekg 함수를 호출하는 셈이다. 그러나 fstr.seekp(0)를 수행하면 ofstreamseekp 함수를 호출하는 것이다. 이 함수들은 &seekg와 &seekp로 각자 주소가 따로 있다 그러나 ( fstr.seekp(0)과 같이) 멤버 함수를 호출하면 실제로는 seekp(&fstr, 0)를 수행하고 있는 것이다.

그러나 여기에서 문제는 &fstr이 객체의 주소를 올바르게 나타내지 못한다는 것이다. seekpofstream에 작동하고, 그 객체는 &fstr에서 시작하지 않는다. (fstream (a)이므로) &(fstr + sizeof(ifstream))에서 시작한다.

그래서 컴파일러는 상속을 사용하는 클래스의 멤버 함수를 반드시 그 객체의 상대 위치로 교정해 호출해야 한다.

그렇지만 다음과 같이 정의하고

    ostream &(fstream::*ptr)(ios::off_type step, ios::seekdir org) = &seekp;

(fstr->*)ptr(0)를 수행하면 컴파일러는 어느 함수가 실제로 호출되는지 더 이상 알지 못한다. 컴파일러는 그저 그 함수의 주소를 받을 뿐이다. 컴파일러의 문제를 해결하기 위해 (ofstream 객체의 위치를 찾기 위하여) 이제 shift에 멤버 포인터 자체가 저장된다. 그 때문에 바로 함수 포인터를 사용할 때 데이터 필드가 더 필요하다.

다음은 구체적인 예제이다. 먼저 구조체 2개를 정의한다. 각자 멤버 함수가 있다 (지면을 아끼기 위해 모두 한 줄짜리 인라인으로 구현한다.):

    struct A
    {
        int a;
    };

    struct B
    {
        int b;
        void bfun() {}
    };
다음 C를 정의한다. A (first)와 B (next)를 상속받는다 (ifstreamofstream을 내장한 fstream과 비슷하다.):
    struct C: public A, public B
    {};
다음, main에서 C 객체와 공용체를 하나 정의한다. 공용체의 실체마다 같은 멤버 함수의 주소 &B::bfun을 받는다. 그러나 BPTR.ptr은 그것을 구조체 B의 세계에 있는 멤버로 보는 반면에, CPTR.ptr은 그것을 구조체 C의 세계에 있는 멤버로 간주한다.

공용체에 값들을 주면 value[] 배열을 사용하여 ptr 필드에 있는 값을 보여준다 (아래 참고):

    int main()
    {
        union BPTR
        {
            void (B::*ptr)();
            unsigned long value[2];
        };
        BPTR bp;
        bp.ptr = &B::bfun;
        cout << hex << bp.value[0] << ' ' << bp.value[1] << dec << '\n';

        union CPTR
        {
            void (C::*ptr)();
            unsigned long value[2];
        };
        CPTR cp;
        cp.ptr = &C::bfun;
        cout << hex << cp.value[0] << ' ' << cp.value[1] << dec << '\n';
    }
이 프로그램을 실행하면 다음과 같이 출력한다.
    400b0c 0
    400b0c 4
(두 번째 줄에 있는 첫 번째) 주소 값은 여러분과 다를 수 있다. 이 함수의 주소는 같다는 것을 눈여겨보라. 그러나 C 세계에서 B 객체는 A 객체를 넘어 존재하고 A 객체는 4 바이트이므로 4를 `this' 포인터의 값에 더해 그 함수를 C 객체로부터 호출해야 한다. 그것이 바로 정확하게 포인터의 두 번째 필드에 있는 shift 값이 컴파일러에게 알려주는 것이다.