상세 컨텐츠

본문 제목

C++ 멤버 포인터 연산자

C++/C++98

by deulee 2023. 8. 9. 14:41

본문

1. 멤버 포인터 변수

멤버 포인터 변수란 특정 클래스(구조체 포함)에 속한 멤버만을 가리키는 포인터이다.

 

선언 형식은 다음과 같다.

타입 클래스::*이름;

1. 포인터 변수이므로 대상체의 타입 필요.

2. 특정 클래스 소속의 변수만을 가리킬 수 있으니 클래스 소속을 밝혀야 함.

3. 소속 뒤에 포인터임을 나타내는 구두점 "*"를 적음

4. 그리고 변수 이름을 적음

 

예시를 한번 보도록 하자.

#include <iostream>

class MyClass
{
public:
	int i, j;
	double d;
};

int main(void)
{
	MyClass C;

	int MyClass::*pi;
	double MyClass::*pd;
	pi = &MyClass::i;
	pi = &MyClass::j;
	pd = &MyClass::d;
	return 0;
}

위의 예시에서 보면 알겠지만 "pi", "pd"는 MyClass 외부에 있는 변수이기 때문에 이 변수가 클래스 내부의 변수를 가리키기 위해서는 대상체 멤버는 public으로 선언되어야 한다.

 

이때 대입식을 보도록 하자.

멤버 포인터 변수 = &Class::Member

이 대입식을 보면 "특정 변수의 번지를 가리키고 있지 않고" "클래스의 어떤 멤버를 가리킬 것"인가만 초기화한것을 알 수 있다.

 

멤버 포인터 변수에 대입되는 번지가 결정되는 것이 아니라 가리키는 멤버가 클래스의 어디쯤에 있는지 위치에 대한 정보만을 가지는 것이다.

 

즉, "클래스 전체를 하나의 작은 주소 공간으로 보고 클래스의 멤버 위치를 기억하는 것"이다.

 

그래서 특정 객체의 멤버를 액세스할 때는 "멤버 포인터 연산자"라는 특수한 연산자가 필요하다.

Obj.*mp
pObj->*mp
  • ".*" 연산자
    • 좌변의 객체에서 멤버 포인터 변수 mp가 가리키는 멤버를 읽는다.
    • 값을 바꿀 수 있음 (상수가 아니면)
  • "->*" 연산자
    • 좌변의 포인터가 가리키는 객체에서 mp가 가리키는 멤버를 읽는다.

이것이 실제로 어떻게 구현되는가는 컴파일러에 따라 구현 방식이 다르지만, 보통 클래스내의 멤버 위치인 오프셋을 기억해 두었다가 ".*" 연산자가 적용될 때 객체의 오프셋을 대상체 타입만큼 읽는 방법을 쓴다.

 

#include <iostream>

class Position
{
public:
	int x, y;
	void outPosition()
	{
		std::cout << x << ' ' << y << std::endl;
	}
};

int main(void)
{
	Position Here;
	Position *pPos = &Here;
	int Position::*pi;

	pi = &Position::x;
	Here.*pi = 30;
	pi = &Position::y;
	Here.*pi = 10;
	
	pPos->*pi = 5;
	pPos->outPosition();
	return 0;
}

사실 ".*"든 "->*"는 원리는 일반 포인터와 원리가 똑같다.

int *pi = &i;
*pi = 30;

"*" 연산자를 통해 가리키고 있는 객체에 접근하는 것의 원리를 이용하고 있는 것이다.

 

즉 다음은 같은 표현식이다.

Here.*pi == Here.x // 똑같은 표현

가리킬 수 있는 범위가 객체 내부의 멤버로 한정지은 것 뿐이지 일반 포인터에 비해 대상체를 간접적으로 액세스한다는 면에서 동일하다.


2. 멤버 포인터 연산자의 활용

그럼 멤버 포인터 변수로 간접적으로 멤버를 액세스를 하게 되면 무슨 장점이 있을까?

 

사실 "멤버 변수"를 참조하는 것은 별로 의미가 없으며 "멤버 함수"를 간접적으로 호출할 수 있다는 면에서 실용성이 있다.

 

#include <iostream>

class Test
{
public:
	void Op1(int a, int b) { std::cout << a + b << std::endl; }
	void Op2(int a, int b) { std::cout << a - b << std::endl; }
	void Op3(int a, int b) { std::cout << a * b << std::endl; }
};

int main(void)
{
	int ch;
	Test t;
	int a = 3, b = 4;

	std::cin >> ch;
	switch (ch) {
		case 0:
			t.Op1(a, b);
			break;
		case 1:
			t.Op2(a, b);
			break;
		case 2:
			t.Op3(a, b);
			break;
	}
	return 0;
}

위의 예제를 보면 switch 문으로 다중 분기하는 것을 보고 있을 것이다. 지금은 개수가 적어서 다행이지 만약 수십개가 넘는다면 골치가 아플 것이다.

 

즉, 호출할 함수가 아주 많은데 그 중 하나를 미리 결정해 놓고 싶을 때 쓸 수 있는 문법적 장치는 "함수 포인터"이다. 어떤 함수를 호출할 것인가를 미리 결정하고 필요할 때 함수 포인터로부터 원하는 함수를 호출하는 원리이다.

 

위의 예제의 경우 "함수 포인터 배열"을 만들어 놓고 입력된 첨자로부터 어떤 함수를 호출할 것인가를 결정하게 만들면 편할 것이다.

 

void (*pf)(int, int);
pf = t.Op1; // 이게 가능할까??

위에는 함수 포인터의 선언 방법이다. 근데 이게 가능할까?

 

결론부터 말하면 안된다.

 

멤버 함수는 반드시 호출하는 객체에 대한 정보를 가지는 "this"라는 암시적인 인수를 전달받아야 한다. 그렇기 때문에 클래스의 멤버를 가리키는 멤버 포인터 변수와 멤버 포인터 연산자가 필요한 것이다.

#include <iostream>

class Test;
typedef void (Test::*fpop)(int, int);

class Test
{
public:
	void Op1(int a, int b) { std::cout << a + b << std::endl; }
	void Op2(int a, int b) { std::cout << a - b << std::endl; }
	void Op3(int a, int b) { std::cout << a * b << std::endl; }
};

int main(void)
{
	int ch;
	Test t;
	int a = 3, b = 4;
	
	static void (Test::*fptr)(int, int) = &Test::Op1;
	(t.*fptr)(a, b);
	static fpop arop[3] = {&Test::Op1, &Test::Op2, &Test::Op3};
	std::cin >> ch;
	(t.*arop[ch])(a, b);
	return 0;
}

"Test" 클래스 선언문 앞에 "fpop" 라는 타입을 정의하는데 "fpop"는 "int"형 둘을 인수로 최하고 리턴값이 없는 Test 클래스의 멤버 함수에 대한 "포인터 타입"이다.

 

main에서는 이런 타입의 배열 "arop"을 선언하고 각 요소를 각 멤버 함수에 맞게 초기화해두었고 첨자를 선택하면 배열을 통해 O(1)의 시간으로 바로 호출할 수 있기 했다.

 

함수 포인터의 장점 중 하나는 함수를 다른 함수의 인수로 전달할 수 있다는 점이다.

#include <iostream>

class Test;
typedef void (Test::*fpop)(int, int);

class Test
{
public:
	void DoCalc(fpop fp, int a, int b)
	{
		std::cout << "연산 결과는 다음이다" << std::endl;
		(this->*fp)(a, b);
	}
	void Op1(int a, int b) { std::cout << a + b << std::endl; }
	void Op2(int a, int b) { std::cout << a - b << std::endl; }
	void Op3(int a, int b) { std::cout << a * b << std::endl; }
};

int main(void)
{
	int ch;
	Test t;
	int a = 3, b = 4;
	
	static fpop arop[3] = {&Test::Op1, &Test::Op2, &Test::Op3};
	std::cin >> ch;
	t.DoCalc(arop[ch], a, b); // 함수를 인수로 넘김
	return 0;
}

예시를 들다보니 실용성은 없지만, 실용성 있는 예시를 하나 소개하겠다.

 

트리를 순회하는 함수를 작성하고 싶은데 전위, 중위, 후의, 층별 순회 함수를 각각 만들어 두고 이 멤버 함수의 번지를 인수로 전달하는 방법을 사용할 수 있다. 만약 멤버 함수를 인수로 전달할 수 없다면 매 순회 방법별로 개별 함수를 일일이 만들어야 할 것이다.


3. 멤버 포인터의 특징

우선 상속 관계에 있는 클래스의 멤버를 가리킬 때의 특징을 보도록 하자.

#include <iostream>

class A
{
public:
	int a;
};

class B : public A
{
public:
	int b;
};

int main(void)
{
	int A::*pA;
	int B::*pB;

	pA = &A::a;
	pB = &B::b;
	pB = &A::a;
	pB = &B::a;
	// pa = &B::b; 당연히 안됨
	return 0;
}
  • "pA = &A::a;" 와 "pB = &B::b;"
    • 타입이 완전히 일치하므로 당연히 가능함
  • "pB = &A::a" 와 "pB = &B::a"
    • 타입이 다르지만 정상적으로 가능함. 왜냐하면 B는 A로 부터 파생되어서 A에 속한 모든 멤버를 가지고 있음
  • "pa = &B::b"
    • 부모 클래스는 자식의 멤버를 가지지 않기 때문에 불가능.

요약하자면, "멤버 포인터 변수는 타입만 일치하다면 기반 클래스로부터 상속받은 멤버도 가리킬 수 있다". 단, 다중 상속에 의해 한 멤버가 두 번 상속되었을 경우는 실제 어떤 멤버를 가리켜야 할 지 모호하므로 에러로 처리한다.

 

또 다른 특징은 멤버 포인터 변수는 "정적 멤버 변수"를 가리킬 수 없으며 "레퍼런스 멤버"를 가리킬 수도 없다.

#include <iostream>

class A
{
public:
	int& ri;
	static int a;
};

int A::a = 4;

int main(void)
{
	// int	A::*ptr = &A::a; // 에러
	int* ptr = &A::a; // 정적 변수를 가리키려면 일반 포인터처럼 선언해야 한다.

	// int	A::*ptr = &A::ri; // 에러
	std::cout << *ptr << std::endl;
	return 0;
}

사실 "정적 멤버 변수"는 클래스 소속일 뿐 객체와는 별개의 변수로 외부에서 선언된 전역 변수랑 전혀 다를바가 없다.

 

그렇기 때문에 일반 포인터 변수로 참조해야한다.

 

그리고 레퍼런스 멤버의 번지도 대입할 수 없는데 C++은 멤버에 대한 레퍼런스라는 개념은 제공하지 않는다.

 

또 다른 특징은, "증감 연산자를 쓸 수 없다는 것이다". 이는 포인터가 증감 연산자를 사용하지 못하는 것과 같은 원리다.


출처

http://www.soen.kr/lecture/ccpp/cpplec.htm

 

C/C++ 강좌

 

www.soen.kr

 

'C++ > C++98' 카테고리의 다른 글

C++ Cast 연산자  (0) 2023.08.09
C++ RTTI  (0) 2023.08.09
C++ 예외의 비용  (0) 2023.08.09
C++ 예외 지정  (0) 2023.08.09
C++ 표준 예외  (0) 2023.08.08

관련글 더보기