본문 바로가기

책 스터디

[Exceptional C++ Style] 16. private는 얼마나 비공개적인가?

이번 챕터는 private 키워드의 비공개성에 대해 접근성과 가시성이라는 두 가지 측면에서 설명하고 있다.
그리고, private 멤버에 대해서 클래스 외부의 non-friend 코드가 private 이름에 접근할 수 있는 방법들에 대해서 설명하고 있지만, 
이 부분은 사실 이렇게하면 할 수 있다는 이야기이지 바람직한 부분은 아니기에 굳이 정리하지 않겠다.

물론 아주 기초적인 내용이지만, 한 번쯤 정독과 고찰을 해 볼 필요는 있어 보인다.
특히, 컴파일러가 함수 호출을 결정하는 순서는 가급적 확실하게 이해를 하고 있어야 한다.


고수 질문

Twice 함수들이 링크 과정에 포함되는 다른 어떤 번역 단위에 정의되어 있다고 할 때(cpp에 되어 있겟지),
다음의 프로그램이 제대로 컴파일, 실행될까? 아니라면 왜 그럴까?
#include <complex>

using namespace std;

class Calc
{
public:
    double Twice(double d);

private:
    int Twice(int i);
    complex<float> Twice(complex<float> f);
};

int main()
{
    Calc c;
    c.Twice(21);
    return 0;
}
당연하게도 위 코드를 컴파일하면, "private 멤버에 접근할 수 없다"는 에러가 발생한다.
(쪼~기 아래에서 가시성에 대해 정리하면서, 이것의 원인에 대해 정확하게 작성하겠다)

이쯤에서 다시 한번 C++ 접근 지정자(access specifier)들에 대해 알아보자.

1. public : 멤버 이름은 어떠한 접근 제한 없이 어디서나 쓰일 수 있다.
2. protected : 멤버 이름은 멤버가 선언된 클래스의 멤버들과 친구들, 그리고 파생된 클래스의 멤버들과 친구들만 접근 가능.
3. private : 멤버 이름은 멤버가 선언된 클래스의 멤버들과 친구들만 접근 가능.

위 예제의 c.Twice(21)를 컴파일러가 분석시 오버로드 해소 과정에서 int Twice(int)가 가장 구체적이라고 판단했지만,
이에 접근하려고 보니 private 키워드에 걸려 엑세스 에러가 발생하는 것이다.

기초적인 내용이지만, 클래스 외부에서 private 멤버에 대해 직접적으로든, 간접적(함수 포인터 등)으로든 접근할 수 없다.
간접적으로도 접근할 수 없는 이유는, 함수 이름 자체를 사용할 수 없으므로 함수의 주소를 얻는 것도 불가능하기 때문이다.

아래 예제를 보면 더욱 더 확실해 질 것이다.
class No
{
private:
    virtual void Sat() {}
};

int main()
{
    No no;
    no.Sat();    // 당연히 private 엑세스 에러

    // 이름에 접근할 수 없기 때문에 주소를 얻는 것도 불가능하다. 역시 private 엑세스 에러
    typedef void (*PSat)(void);
    PSat p = &No::Sat;

    return 0;
}
 
가상 함수의 경우도 마찬가지이다. 
파생된 클래스가 private 가상 함수를 재정의(override)하는 것은 가능하지만, 
파생 클래스에서 직접적으로 위치 지정자를 이용하여 부모의 함수를 직접 접근하는 것은 불가능하다.
class Derived : public No
{
    virtual void Sat()    // 재정의는 가능하다.
    {
        No::Sat();    // 하지만, 부모 클래스의 private 함수를 직접 접근할 순 없다.
    }
}
클래스 외부의 코드(비멤버, 비 friend)가 private 멤버 함수의 이름에 접근할 수 있는 방법은 없다.
이제 "private는 얼마나 비공개적인가?"라는 물음에 다음과 같은, 이미 잘 알고 있는 대답을 할 수 있다.
private 멤버 이름은 다른 멤버들과 friend만 접근할 수 있다.

너무나 기초적인 내용을 정리했지만, 이건 어디까지나 접근성의 관점에서 다룬 이야기이다.
이제부터는 가시성이라는 관점에서 접근해 보자.

위 첫번째 예제코드에서 #include <complex> 라인을 지워보면 어떻게 될까?
//#include <complex>    // 주석 처리함

using namespace std;

class Calc
{
public:
    double Twice(double d);

private:
    int Twice(int i);
    // 여기에서 complex에 대해 알지 못하기에 컴파일 에러가 발생한다.
    complex<float> Twice(complext<float> f);
};
 
이것이 바로 접근성과는 또 다른 관점인 가시성에 대한 예제이다.

private 멤버에 대한 접근성 관점에서는 다른 멤버와 friend들만 접근이 가능하지만,
가시성의 관점에서는 해당 클래스를 볼 수 있는 모든 코드들이 private 멤버를 볼 수는 있는 것이다.

이를 기초로 다음과 같이 private의 비공개성에 대해 정리를 추가할 수 있을 것이다.
private 멤버 이름은 다른 멤버들과 friend만 접근할 수 있지만, 
클래스를 볼 수 있는 모든 코드가 볼 수 있기에,
컴파일 과정의 함수 오버로드 해소 과정이나 모호한 함수 처리 등의 호출 해소 과정에는 동일하게 참여한다.
 
컴파일러는 Twice에 대한 호출을 해소하려 들 때, 다음 세 가지 순서를 거친다.

이거 중요하다!!!!

1. Name Lookup(이름 조회)

가장 먼저, 컴파일러는 가까운 범위에서부터 거슬러 올라가면서 Twice라는 이름을 찾는다. 
이름이 하나라도 있는 범위를 만나면 거기서 조회가 끝나며, 거기에 있는 이름들이 후보가 된다.
맨 처음 예제의 경우 Calc의 범위에 Twice 후보가 세 개 있고, 그 세 개가 후보가 된다.

2. 오버로드 해소

그런 다음에는 후보들 중 주어진 호출과 가장 일치하는 하나를 고르는 오버로드 해소를 수행한다.
예제의 경우 c.Twice(21)로 호출하였으므로 가장 일치하는 형식은 int 타입이다.
세 개의 후보 (double, int, std::complex) 중 int 타입인 int Twice(int)가 선택된다.

3. 접근성 점검

마지막으로, 컴파일러는 선택된 함수에 대해 접근성 점검을 수행한다.

맨 처음 예제에서 c.Twice(21)이 실패한 것은 private 멤버인 int Twice(int)가
가시성에 의해 오버로드 해소 과정에서 노출되어 그 과정에 참여되었기 때문이다.

접근할 수 있는 유일한 함수인 Twice(double)이 int와 호환이 되긴 하지만,
이미 오버로드 해소 과정에서 int Twice(int)가 먼저 선택되었기 때문에 이는 무의미하다.
얼마나 일치하는가?가 접근 가능한가? 보다 우선되기 때문이다.

오버로드 해소 과정에 참여한다는 이야기는 모호한 일치를 걸러낼 때에도 적용된다는 이야기이다.
모호한 일치가 있으면 오버로드 해소 과정에서 가장 적합한 것을 찾을 수 없기에 에러가 발생한다.
#include <complex>

using namespace std;

class Calc
{
public:
    double Twice(double d);
private:
    unsigned Twice(unsigned i);    // unsigned int도...double도 될 수 있다.
    complex<float> Twice(complex<float> f);
};

int main()
{
    Calc c;
    c.Twice(21);    // private access 오류가 아닌 ambiguous 에러가 먼저 발생한다
    return 0;
}