포인터는 왜쓰고
업&다운 캐스팅은 뭘까요?
설명에 앞서 저는 업캐스팅을 이해하려면 포인터부터 알아야 한다고 생각합니다. 해서 포인터부터 설명을 드리겠습니다.
학창시절 포인터는 C에서 배웠지만 그냥 해당 타입의 메모리 주소를 저장 및 참조(접근)하는 그런건가 보다~ 하고 넘겼었고 업&다운 캐스팅은 왜 쓰는지 도무지 감이 안잡혔었습니다. 그래서 서적이나 블로그 등 많이 찾아봤지만 이 질문에 대한 명확한 답을 찾기가 어려웠습니다. 많은 분들이 저와 같은 고민을 했으리라 생각합 아니 하고 싶습니다^^.. 때문에 제가 아는 선에서 여러분에게 최대한의 이해를 돕고자 포스팅을 하게 되었습니다. 그리고 "C++ 클래스에 대한 이해 - 다형성"를 읽는데 도움이 되실겁니다.
서두가 길었습니다. 시작하시죠
먼저 포인터란 주소를 저장하는(가리키는) 변수입니다. 우리는 해당 변수에 *, -> 연산자를 통해 주소에 해당하는 실체 값에 접근할 수 있습니다.
굳이 이걸 왜쓰나? 그냥 int num = 10; 이렇게 사용하면 안되는건가요?
변수의 생명주기 때문입니다.
일반적으로 변수는 메모리 구조에서 스택(Stack)에 할당(push) 됩니다. 이 스택에는 지역변수, 함수의 복귀주소, 매개변수 등이 저장되는데 이들은 생성된 블록 내에서만 유효합니다. 즉 함수가 종료하거나 블럭이 닫힌다면 스택에서 제거(pop)가 되어 프로그래머가 더이상 컨트롤 할 수 없는 상태가 되는거죠.
개발을 하다보면 함수나 DLL에서 생성된 객체를 가져다 써야하고 어쩌면 프로그램이 종료할때 까지도 그 객체가 유지가 되어야 한다면 우리는 포인터를 쓸 수 밖에 없습니다. 물론 포인터를 쓰는 이유는 더 있고 장점과 단점이 존재합니다만 개인적으로 지금부터 배울 내용만 캐치하시면 자연스레 머릿속에 들어올거라고 생각합니다.
int* GetAddress()
{
int num = 10; //지역변수 선언
int* pNum = # //지역변수 선언 및 초기화
return pNum; //pNUm이 가지는 지역변수 num의 주소를 반환
}
int main(void)
{
int* pNum = GetAddress(); //반환된 주소로 초기화
std::cout << *pNum << std::endl;//제어권을 잃은 상태로서, 반환된 주소에 해당하는 //메모리에 남은 값을 출력
return 0;
}
결과는 10으로 잘 나오는데요?
GetAddress()에서 생성된 num에 해당하는 메모리는 더 이상 유효하지 않고 임시로만 남아있는 상태이기 때문입니다. 이는 언제 쓰레기 값이 되어 언제 값이 바뀔지 모릅니다. 만약 여러분이 이것 저것 다른 객체를 생성하다가 pNum을 참조했을 때 다른 값이 나올 수 있다는 거죠.
그럼 여지껏 설명한 포인터의 의미가 사라지겠네요? 당연히 pNum이 참조하고 있는 메모리가 지역변수이기 때문에 사실상 이런식의 포인터 사용은 의미가 없기는 합니다. 또한 블록이나 함수내에서 생성된 모든 변수는 (포인터변수도 해당) 지역변수라는 점도 알아두세요.
이제부터가 본 내용입니다. 메모리 구조에는 힙(Heap)이 있는데, 실행시간(run time)중에 동적으로 할당된 변수들이 저장되는 공간입니다. 프로그래머가 직접 new와 delete 연산자를 사용해서 메모리의 할당과 해제를 관여하는 곳이지요. 이 곳에 저장된 변수들은 해제를 해주지 않으면 프로그램이 종료할 때 까지 영원히 남아있습니다. 따라서 프로그래머가 해당 주소를 저장하는 변수를 반환을 하던~ 참조 카운팅을 늘리던~ 계속해서 컨트롤하면서(주소를 0xFFFFFFFF 형식으로 쓸 수는 없으니깐요) 직접 해제를 해주지 않는다면 프로그램이 종료할 때 까지 계속해서 read & write를 할 수 있단 소리! 단 해제를 해주지 않을 시 메모리 누수의 원인이 됩니다. 그리고 그 책임은 온전히 프로그래머의 몫이기 때문에 반드시 주의합시다.
int* GetAddress()
{
int* pNum = new int(5); //int 타입의 5를 가지고 있는 메모리를 pNum에 동적할당
return pNum; //pNum이 가리키고 있는 주소 반환
}
int main(void)
{
int* pData = GetAddress(); //pNum이 가지는 주소로 pData 초기화
std::cout << *pData << std::endl; //pData가 가리키는 주소에 해당하는 값 출력
int* pNumAddr = pData; //pNumAddr도 pData와 같은 주소를 가리킴(참조카운팅 +)
delete pData; //pData가 가리키는 주소에 해당하는 메모리 해제
std::cout << *pNumAddr << std::endl; //쓰레기 값 출력
return 0;
}
포인터를 사용할 때 중요한 한가지! 주소를 매개변수의 인자로 넘기거나, 반환을 받을 때 반드시 nullptr 체크를 해줄 것.
int* GetAddress()
{
int* pNum = nullptr; return pNum;
}
int main(void)
{
int* pNum = GetAddress();
if (pNum)
std::cout << *pNum << std::endl; else std::cout << "nullptr" << endl;
return 0;
}
자 이제 업캐스팅과 다운캐스팅에 대해 알아봅시다.
포인터를 먼저 설명했으니 업캐스팅과 다운캐스팅은 포인터를 이용해서 뭔가를 한다..라고 감이오실 겁니다. 상속관계의 객체들은 서로를 가로지르는 타입캐스팅이 가능하다고 일전에 말씀드렸었죠.
업캐스팅이란 기본클래스 포인터로 파생클래스 객체를 가리키는 것 입니다. 그냥 파생클래스 객체를 선언해서 가져다 쓰면 되지 않나요? 이걸 왜 하느냐..?
다형성을 이용해서 코드 재사용성을 높인다. 라고 한마디로 정리할 수 있을거 같습니다.
["C++ 클래스에 대한 이해 - 다형성"를 보시면 가상함수의 오버라이딩, 동적바인딩과 같이 업캐스팅의 용도(다형성)가 설명되어 있으니 참고하세요^^]
코드예제를 한번 보시죠.
class CBase
{
public: virtual void Show()
{
std::cout << "CBase" << std::endl;
}
};
class CDerived1 : public CBase
{
public: virtual void Show()
{
std::cout << "CDerived1" << std::endl;
} //overriding
};
class CDerived2 : public CBase
{
public: virtual void Show()
{
std::cout << "CDerived2" << std::endl;
} //overriding
};
해당 결과가 나오게끔 여러분들이 코드를 한번 작성해보세요!
main int main(void)
{
CBase base;
CDerived1 derived1;
CDerived2 derived2;
base.Show();
derived1.Show();
derived2.Show();
return 0;
}
1번 : 이런 코드가 될 수도 있고요.
main int main(void)
{
CBase* pInterface = nullptr;
CBase* pBase = new CBase;
CDerived1* pDerived1 = new CDerived1;
CDerived2* pDerived2 = new CDerived2;
pInterface = pBase;
pInterface->Show();
pInterface = pDerived1;
pInterface->Show();
pInterface = pDerived2;
pInterface->Show();
delete pBase;
delete pDerived1;
delete pDerived2;
return 0;
}
2번 : 포인터, 동적바인딩에 대해 이해를 하신분이면 이런 코드가 작성될 수도 있어요.
main int main(void)
{
std::string classNames[3] = {"CBase", "CDerived1", "CDerived2" };
std::string className = "";
std::map<std::string, CBase*> mapData;
CBase* pInterface = nullptr;
CBase* pBase = new CBase;
CDerived1* pDerived1 = new CDerived1;
CDerived2* pDerived2 = new CDerived2;
mapData.insert(std::make_pair(classNames[0], pBase));
mapData.insert(std::make_pair(classNames[1], pDerived1));
mapData.insert(std::make_pair(classNames[2], pDerived2));
for (int idx = 0; idx < 3; idx++)
{
className = classNames[idx];
pInterface = mapData.find(className)->second; //업 캐스팅 pInterface->Show();
}
mapData.clear();
delete pBase;
delete pDerived1;
delete pDerived2;
return 0;
}
3. 앞선 코드들보다 확연히 깁니다. 코드의 재사용이라며? 왜이렇게 길어~ 라고 생각하실 수 있지만 핵심은 for문과 map입니다. 객체야 동적할당을 통해 힙(Heap)에 남아있으니 주소만 알고있으면 된다고 했죠. 그래서 단 한번의 객체생성으로 map에 해당 클래스의 이름과 함께 객체의 주소를 저장시켜 놓는 것입니다. 초기화라고 해두죠. 이 map을 어디에서든(main이든 함수든) 가져다 쓸 수 있게끔 만들어 놓는다면(싱글톤&팩토리 패턴 등등 활용) 기본클래스 타입의 포인터변수만 선언하고 그 포인터변수로 업 캐스팅 하게되면서 for문 속 단 세줄로 효율적으로 구현할 수 있겠죠? 여러분이라면 어떤 방식을 선호 하시겠습니까?
프로그램의 구조나 실행 시퀀스에 따라 1, 2, 3 모두 적재적소에 적절히 써주시면 되겠습니다. 혹여 3번 코드가 이해가 되지 않는다면 STL의 map을 공부하시면 됩니다.
다운캐스팅은 동일한 타입의 포인터가 동일한 타입을 가리키는 것입니다.
업 캐스팅을 하게되면 기본 클래스에 정의된 멤버만 호출할 수 있기 때문에 파생클래스 고유의 기능을 사용할 수가 없습니다. 그럴때 잠시 다운캐스팅을 해서 기본클래스포인터->파생클래스객체 의 형태에서 파생클래스포인터->파생클래스객체 형태로 원래 타입으로 변환을 해주는 거죠.
이때 파생클래스는 서로 동일한 타입이어야 합니다. 그리고 형 변환시에는 반드시 명시적 형 변환을 해줘야 합니다.
기본클래스->파생클래스 형태의 업 캐스팅은 어차피 상위클래스가 하위클래스를 가리키는거라 컴파일러가 암묵적인 형 변환을 해줍니다. 그러나 다운 캐스팅은 수 많은 파생클래스 중에 해당 객체가 어떤 타입인지 명시를 해주지 않는다면 에러를 발생시킬수 있기 때문입니다. 아이폰이 갤럭시를 가리킬 수는 없잖아요? ㅎㅎ
class CBase
{
public: virtual void Show()
{
std::cout << "CBase" << std::endl;
}
void A()
{
std::cout << "A의 고유 기능" << std::endl;
}
};
class CDerived1 : public CBase
{
public: virtual void Show()
{
std::cout << "CDerived1" << std::endl;
}
void B()
{
std::cout << "B의 고유 기능" << std::endl;
}
};
class CDerived2 : public CBase
{
public: virtual void Show()
{
std::cout << "CDerived2" << std::endl;
}
void C()
{
std::cout << "C의 고유 기능" << std::endl;
}
};
int main(void)
{
std::string classNames[3] = { "CBase", "CDerived1", "CDerived2" };
std::string className = "";
std::map<std::string, CBase*> mapData;
CBase* pInterface = nullptr;
CBase* pBase = new CBase;
CDerived1* pDerived1 = new CDerived1;
CDerived2* pDerived2 = new CDerived2;
mapData.insert(std::make_pair(classNames[0], pBase));
mapData.insert(std::make_pair(classNames[1], pDerived1));
mapData.insert(std::make_pair(classNames[2], pDerived2));
for (int idx = 0; idx < 3; idx++)
{
className = classNames[idx];
pInterface = mapData.find(className)->second; //업 캐스팅
switch (idx)
{
case 0:
pBase = (CBase*)pInterface; //다운 캐스팅(pInterface를 CBase로 타입 캐스팅)
pBase->A();
break;
case 1:
pDerived1 = (CDerived1*)pInterface; //다운 캐스팅
pDerived1->B();
break;
case 2:
pDerived2 = (CDerived2*)pInterface; //다운 캐스팅
pDerived2->C();
break;
default:
break;
}
}
mapData.clear();
delete pBase;
delete pDerived1;
delete pDerived2;
return 0;
}
각각의 클래스들이 고유멤버함수를 가지고 있으며,, 업 캐스팅된 상태에서 그 기능을 호출하기 위해 다운 캐스팅을 하는 코드입니다. 역시 for문만 집중적으로 보시면 됩니다.
오늘 포스팅을 끝으로 C++ 클래스에 대한 이해 시리즈와 함께 객체지향을 최대한 이해하셨으면 좋겠습니다.
현재 다운캐스팅에서 쓰인 형 변환은 C타입 캐스팅입니다. 어떤 제약조건도 없이 프로그래머가 지정한 타입으로 그냥 캐스팅 시켜버리기 때문에 실수가 있으면 바로 에러로 돌아옵니다. 모던 C++에서는 RTTI(Run Time Type Information)와 함께 안전하고 강력한 다양한 방식의 타입 캐스팅을 지원합니다. 이 내용은 추후 천천히 다뤄볼게요.
포스팅 내용에 오류가 있거나 지적사항이 있다면 댓글로 달아주세요. 배움을 멈추지 않는 Good Programmer가 되겠습니다. 감사합니다.
'C++' 카테고리의 다른 글
[C++] call by value, call by address, call by reference 차이 (0) | 2020.07.21 |
---|---|
[C++] 생성자(Constructor) 소멸자(Destructor) (0) | 2020.07.19 |
[C++] 객체지향과 클래스에 대해 - 다형성(Polymorphism) (0) | 2020.06.26 |
[C++] 객체지향과 클래스에 대해 - 상속(Inheritance) (0) | 2020.06.20 |
[C++] 객체지향과 클래스에 대해 - 캡슐화(Encapsulation) (0) | 2020.06.16 |
댓글