상속 시의 메모리 상태
#include <iostream>
class Person
{
public:
int personVal = 0;
};
class Student : public Person
{
public:
int studentVal = 0;
};
class UnivStudent : public Student
{
public:
int univStudentVal = 0;
};
int main(int argc, char** argv)
{
UnivStudent univStudent;
std::cout << sizeof(Person) << std::endl;
std::cout << sizeof(Student) << std::endl;
std::cout << sizeof(UnivStudent) << std::endl;
std::cout << std::endl;
std::cout << std::hex << &univStudent << std::endl;
std::cout << std::hex << &univStudent.personVal << std::endl;
std::cout << std::hex << &univStudent.studentVal << std::endl;
std::cout << std::hex << &univStudent.univStudentVal << std::endl;
std::cout << std::endl;
return 0;
}
4
8
12
0065FBF4
0065FBF4
0065FBF8
0065FBFC
간단히 객체의 메모리의 주소를 알아보는 코드입니다.
결과는 컴파일러 혹은 OS에 따라 다를 수 있으며, 메모리의 주소값 역시 실행시마다 값이 달라질 수 있습니다.
먼저 위 출력 결과를 기준으로 각 클래스의 크기를 살펴보면
Person 클래스는 4byte, Student클래스는 8byte, UnivStudent클래스는 12byte를 차지합니다.
위 예제에서 univStudent 인스턴스의 메모리 주소는 0065FBF4로 부터 시작합니다.
이는 personVal의 주소와 동일합니다.
이후 4byte 간격을 두고 studentVal, 다시 4byte 간격을 두고 univStudentVal가 위치해 있습니다.
이 순서는 상속 단계의 순서와 일치합니다.
즉, 부모의 멤버일수록 메모리의 앞쪽(인스턴스의 메모리주소)에 위치합니다.
부모 자식간의 캐스팅
기본적으로 C / C++에서 메모리 단계의 작업은 별다른 제약이 없습니다.
Person* ptr = (Person *)0xFFFFEE;
위와 같은 대입식도 아무런 문제 없이 컴파일 됩니다.
해당 주소가 실제로 있는지 없는지는 컴파일 타임에 밝혀낼 수 없기 때문입니다.
게다가 메모리 작업은 런타임에 에러가 난다는 것 조차 보장되지 않습니다.
운이 좋거나 혹은 나빠서 해당 위치에 메모리가 할당되어 있을 수 있으며,
후자의 경우 전혀 엉뚱한 메모리를 수정할 가능성이 있습니다.
단지 포인터는 해당 위치부터 얼마만큼의 길이를 어떠한 데이터 형으로 볼 것인지를 정의할 뿐입니다.
UnivStudent는 12byte입니다.UnivStudent의 포인터를 사용하면
univStudent 인스턴스의 시작 지점인 0065FBF4으로 부터 12byte까지를 UnivStudent형으로 봅니다.
univStudent 인스턴스의 주소값 0065FBF4을 가지고 있는 포인터를 Student 포인터형으로 캐스팅한다면,
univStudent 인스턴스의 시작 지점인 0065FBF4으로 부터 8byte까지를 Student형으로 보게 됩니다.
매우 직관적으로, 부모 자식간의 캐스팅이 자유롭게 가능할 것이라는 사실을 알 수 있습니다.
항상 메모리는 부모의 멤버부터 상속 계통에 따라 할당되기 때문에
단지 인스턴스의 시작 주소로부터 캐스팅할 클래스의 크기만큼만 사용하면 됩니다.
자식 -> 부모의 캐스팅은 업캐스팅(Up Casting)이라고 하며
크기가 줄어드는 형태이기 때문에 대부분의 경우에는 문제가 없습니다.
당연하게도 부모의 포인터로는 부모의 멤버 변수 혹은 메소드에만 접근할 수 있습니다.
부모 -> 자식의 경우는 다운 캐스팅(Down Casting)이라고 합니다.
이 경우 크기가 늘어나는 형태이기 때문에 주의할 필요가 있습니다.
자식의 포인터 역시 당연하게도 자식의 모든 멤버 변수와 메소드에 접근할 수 있지만,
실제로 인스턴스가 해당하는 메모리 범위까지 할당되어 있을지는 보장할 수 없습니다.
다운 캐스팅은 주로 업 캐스팅 된 인스턴스를 다시 본래의 자료형으로 되돌릴 때 사용합니다.
캐스팅의 활용
이러한 캐스팅은 공통의 규격을 마련할 때 유용합니다.
메소드의 리턴 자료형을 부모의 포인터로 지정하면 다양한 자식타입을 하나의 메소드로 처리할 수 있게 됩니다.
또한 부모의 포인터를 보관하는 컨테이너를 사용하면 자식 타입들을 한곳에 모아 보관할 수 있습니다.
단, RTTI(RunTime Type Information)를 사용하거나 이와 비슷한 기능을 구현하여
해당 인스턴스가 무슨 클래스인지 알아내어 안전한 캐스팅을 할 필요가 있습니다.
아래는 본래 인스턴스가 어떠한 클래스인지 알아내는 간단한 예제이며,
생성자 및 구별을 위한 int형 멤버변수 하나를 만들어 구현한 방식입니다.
이 외에도 enum을 활용해도 되고, 다른 의미를 부여한 숫자나 문자열등도 사용할 수 있습니다.
class Person
{
private:
int type = 0;
protected:
Person(int type)
{
this->type = type;
}
public:
Person() {};
int GetType()
{
return type;
}
};
class Student : public Person
{
public:
Student() : Person(1) {};
};
class Employee : public Person
{
public:
Employee() : Person(2) {};
};
void typeChecker(Person* ptr)
{
switch (ptr->GetType())
{
case 0:
std::cout << "Person" << std::endl; break;
case 1:
std::cout << "Student" << std::endl; break;
case 2:
std::cout << "Employee" << std::endl; break;
}
}
int main(int argc, char** argv)
{
Person person;
Student student;
Employee employee;
typeChecker(&student);
typeChecker(&person);
typeChecker(&employee);
return 0;
}
Student
Person
Employee