I'm FanJae.

[C++] Upcasting, Virtual Function 본문

C++/Basic

[C++] Upcasting, Virtual Function

FanJae 2024. 8. 21. 15:48

※ 본 포스트는 코드누리 C++ Basic 강의 내용을 보고 정리한 포스트입니다.

 

1. UpCasting

#include <string>
class Animal
{
	std::string name;
	int age;
public:
	void cry() { }
};

class Dog : public Animal
{
	int color;
public:
	void setColor(int c) {
		color = c;
	}
};

int main()
{
	Dog dog;

	Dog* p1 = &dog; // ok
	// int* p2 = &dog; // Error
	Animal* p3 = &dog;

}

- UpCasting이란, 기반 클래스 포인터로 파생 클래스 객체를 가리키는 행위를 의미한다.

- 메모리 레이아웃 차원에서 보면, 파생 클래스(Derived Class)인 Dog가 기반 클래스(Aniaml)의 객체를 포함하므로, 객체의 메모리 공간의 처음 부분은 Animal의 메모리 공간이다. 따라서, 이와 같은 작업이 가능하다.

- 기반 클래스의 포인터로는 기반 클래스의 멤버에만 접근이 가능하다.

- 만약 파생 클래스의 고유 멤버에 접근하고 싶다면, 명시적으로 캐스팅(static_cast)가 필요하다.

static_cast<Dog*>(p3)->setColor(5);

- 위와같이 변환하여 접근이 가능하다.

 

1-1. Upcasting의 활용 예시

class Animal
{
public:
    int age = 0;
};
class Dog : public Animal {};
class Cat : public Animal {};

void NewYear(Animal *p)
{
    ++(p->age);
}

int main()
{
    Dog dog;
    NewYear(&dog);
    
    Cat cat;
    NewYear(&cat);
    
    std::vector<Dog*> v1; // Dog 만 보관하는 vector
    std::vector<Animal*> v2; // 모든 동물을 보관
}

- Upcasting의 활용

동종(동일 기반 클래스 부터 파생된 클래스)를 처리하는 함수

동종 보관하는 컨테이너


2. 함수 오버라이드(Function override)

#include <iostream>

class Animal
{
    int age;
public:
    void cry() { std::cout << "Animal cry" << std::endl;} // 1
};

class Dog : public Animal
{
public:
    // override
    void cry() { std::cout << "Dog cry" << std::endl;} // 2
};

int main()
{
    Animal a; a.cry() // 1
    Dog    d; d.cry() // 2
    
    Animal *p = &d;
    
    p->cry(); // 1 ? 2
}

- 이와 같이 기반 클래스의 있는 멤버 함수를 파생 클래스에 다시 구현 하는것을 Function Override라고 한다.

- 기존 배웠던 Function Overloading 과 완전히 다른 개념임에 유의 해야한다.

Animal a; a.cry(); // 1
Dog    d; d.cry(); // 2

Animal *p = &d;    // ?

p->cry();

- 맨 위의 2개에 대해서는 쉽게 이해할 수 있다..

- 하지만, Animap *p = &d; 부분은 과연 Animal의 cry()를 호출할까 아니면 Dog의 cry()를 호출할까?

- 결과와 별개로 논리적으로 생각해보면 객체가 실제 가리키고 있는건 Dog이기 때문에 Dog의 Cry()를 호출해야 한다.

- 하지만 이는 Animal의 cry() 를 호출한다. Java, Python등에서는 Dog의 cry()를 호출한다.

- 이와 같이 처리 되는 이유와 C++ 에서 Dog의 cry()를 호출하게 할 방법이 존재한다.

- 우선, 이를 결정하는 함수 바인딩(Function Binding)이라는 개념에 대해 알아보려 한다.


3. 함수 바인딩(Function Binding)

#include <iostream>

class Animal
{
    int age;
public:
    void cry() { std::cout << "Animal cry" << std::endl;} // 1
};

class Dog : public Animal
{
public:
    // override
    void cry() { std::cout << "Dog cry" << std::endl;}
};

int main()
{
    Animal a;
    Dog    d;
    
    Animal *p = &d;
    
    p->cry();
}

- 이와 같은 상황에 p->cry() 표현식을 어느 함수와 연결할지를 결정하는 과정을 함수 바인딩이라고 한다.

- Function Binding에는 Static binding 과 Dynamic Binding 2가지가 존재한다.

 

if ( 사용자 입력 == 1 ) p = &a;

p -> cry();

- p -> cry(); 이전에 이와 같은 코드가 삽입되어 있었다면, 이를 컴파일러가 이를 알아내는 것은 어렵다.

- 따라서 일반적으로는 컴파일 시간에 포인터 타입만 보고 결정하는 것이다.

 

① Static binding(early binding)

- 컴파일러가 컴파일 시간에 함수 호출을 결정한다.

- 컴파일러는 p가 실제로 어느 객체를 가리키는지 컴파일 시간에 알 수 없다.

- 즉, 포인터 타입만을 가지고 함수 호출을 결정하여, Animal cry를 호출한다.

- 빠르지만 논리적이지 않다. C++, C#의 Non-Virtual Function등이 대부분 이 방식으로 작동한다.

 

② Dynamic binding(late binding)

- 컴파일 시간에는 p가 가리키는 곳을 조사하는 기계어 코드를 생성한다.

- 실행 시간에 p가 가리키는 곳을 조사한 이후, 실제 메모리에 있는 객체에 따라 함수 호출을 결정한다.

- 느리지만 논리적이다. C++, C#의 Virtual Function이 이러한 방식으로 작동한다.

 

③ virtual function vs non-virtual function

③-① Virtual Function

- 함수 선언부에 virtual 붙은 멤버 함수

- dynamic binding

 

③-② Non-virtual function

- virtual을 붙이지 않은 멤버 함수

- static binding

#include <iostream>

class Animal
{
    int age;
public:
    
    // static binding
    void cry() { std::cout << "Animal cry" << std::endl;} // 1
    
    // dynamic binding
    virtual void cry2() { std::cout << "Animal cry2" << std::endl;} // 1
    
};

class Dog : public Animal
{
public:
    void cry() { std::cout << "Dog cry" << std::endl;} // 2
    virtual void cry2() { std::cout << "Animal cry2" << std::endl;} // 2
};

int main()
{
    Animal a;
    Dog    d;
    
    Animal *p = &d;
    
    p->cry1(); // Animal
    p->cry2(); // Dog
}

- 이와 같이 Virtual Function을 사용하면 dynamic binding이 일어남을 확인 가능하다.


4. Virtual Function Example

- 우선, 가상함수를 언제, 어떻게 사용하는가?를 알아보자.

- Powerpoint와 같이 다양한 도형을 관리하는 프로그램을 만든다고 생각해보자.

#include <iostream>
#include <vector>

class Rect
{
	public:
        void draw() { std::cout << "draw rect" << std::endl;}
}

class Circle
{
	public:
        void draw() { std::cout << "draw circle" << std::endl;}
}


int main()
{
    std::vector<Rect*> v1;
    std::vector<Circle*> v2;
    
}

- 위와 같이 Rect와 Circle을 별개로 관리하면, 서로 겹치는 도형등의 대한 관리가 어려워진다.

- 따라서, 공통의 기반 클래스를 설계해서 한번에 관리하는 것이 좋다.

 

4-1. 기반 클래스 설계

- 모든 도형의 공통의 기반 클래스가 있다면 모든 종류의 도형을 하나의 컨테이너에 보관할 수 있다.

#include <iostream>
#include <vector>

class Shape
{
}

class Rect : public Shape
{
	public:
        void draw() { std::cout << "draw rect" << std::endl;}
}

class Circle : public Shape
{
	public:
        void draw() { std::cout << "draw circle" << std::endl;}
}


int main()
{
    std::vector<Shape*> v;
    
    while(1)
    {
        int cmd;
        std::cin >> cmd;
        
        if ( cmd == 1 ) // 사각형을 그림.
        {
        //  Rect rc;
            Shape* p = new Rect;
            v.push_back(p);
        }
        else if ( cmd == 2 ) // 원을 그림
        {
            v.push_back( new Circle ); 
        }
        else if ( cmd == 9 )
        {
             for ( auto p : v)
                  p->draw();
        }
    }
}

- 이를 컴파일 해보면, Draw()에 대해서 오류가 발생한다.

- 이는 객체는 Rect 또는 Circle 이지만 Vector에 보관시에는 Shape* 로 보관되므로, draw()를 호출할 수 없다.

① 해결방법 1

- Shape *를 Rect *나 Circle *로 캐스팅한다.

- 다만 문제는, 코드를 작성할 때 어떤 도형인지 알 수 있을까?

p->draw();

- 이것을 캐스팅 하여 도형의 정보를 알아내는 것은 불가능하다. 

 

② 해결방법 2

- Shape 안에 draw() 함수가 있어야 한다.

 

4-2. 공통의 특징은 기반 클래스에도 있어야 한다.

- 모든 파생클래스(Rect, Circle)의 공통의 특징은 반드시 기반 클래스(Shape)에도 있어야 한다.

- 그래야, 기반 클래스 포인터 타입으로 객체를 관리할 때 해당 특징을 사용할 수 있다.

 

Shape 안에 반드시 draw() 함수가 있어야 한다.

#include <iostream>
#include <vector>

class Shape
{
	public:
        void draw() { std::cout << "draw shape" << std::endl;}
}

class Rect : public Shape
{
	public:
        void draw() { std::cout << "draw rect" << std::endl;}
}

class Circle : public Shape
{
	public:
        void draw() { std::cout << "draw circle" << std::endl;}
}


int main()
{
    std::vector<Shape*> v;
    
    while(1)
    {
        int cmd;
        std::cin >> cmd;
        
        if ( cmd == 1 ) // 사각형을 그림.
        {
        //  Rect rc;
            Shape* p = new Rect;
            v.push_back(p);
        }
        else if ( cmd == 2 ) // 원을 그림
        {
            v.push_back( new Circle ); 
        }
        else if ( cmd == 9 )
        {
             for ( auto p : v) // p는 Shape*
                  p->draw();
        }
    }
}

- 이를 실행해보면 p->draw(); 실행시 Draw Shape로만 출력된다.

- 즉, 기반 클래스 함수를 호출하는 문제가 발생한다.

- 여기서도 알 수 있듯, 기반 클래스의 함수를 재정의 할때는 일반 함수를 사용해서는 안된다.

 

4-3. 가상함수 도입

- 기반 클래스 함수 중 파생클래스가 재정의하게 되는 함수는 반드시 가상함수로 만들어야 한다.

#include <iostream>
#include <vector>

class Shape
{
	public:
        virtual void draw() { std::cout << "draw shape" << std::endl;}
}

class Rect : public Shape
{
	public:
        virtual void draw() { std::cout << "draw rect" << std::endl;}
}

class Circle : public Shape
{
	public:
        virtual void draw() { std::cout << "draw circle" << std::endl;}
}


int main()
{
    std::vector<Shape*> v;
    
    while(1)
    {
        int cmd;
        std::cin >> cmd;
        
        if ( cmd == 1 ) // 사각형을 그림.
        {
        //  Rect rc;
            Shape* p = new Rect;
            v.push_back(p);
        }
        else if ( cmd == 2 ) // 원을 그림
        {
            v.push_back( new Circle ); 
        }
        else if ( cmd == 9 )
        {
             for ( auto p : v) // p는 Shape*
                  p->draw();
        }
    }
}

- 이와 같이 구현하면, draw()에 대해서 Shape가 아닌 재정의 된 함수를 부르는 것을 확인 가능하다.


5. Virtual Function 문법

- 가상함수를 만들 때는 함수 앞에 virtual을 붙인다.

- 단, 가상함수를 재정의 할 때는 virtual을 붙여도 되고 붙이지 않아도 되지만, 가독성을 위해서는 붙이는 것이 좋다.

- 가상 함수 재정의시 함수 이름 등에 오타가 있어도 에러가 발생하지 않는다.

- 컴파일러는 새로운 함수를 추가 했다고 생각하지 않는다.

virtual void Dra()
{
}

- 예를들어 Draw()를 쓰려다가 오타를 냈다고 하더라도.. 컴파일러가 이를 새로운 함수를 추가했다고 생각한 것이다.

 

5-1. overrride // C++11

virtual void Draw() override {}

- 가상함수 재정의시 override를 붙이는 것이 안전하다. 

- 이를 붙이면, 컴파일러가 이를 새로운 함수가 아닌 재정의한 함수라고 판단하여 오탈자 등을 지적해준다.

 

5-2. Virtual Desturctor

#include <iostream>
using namespace std;

class Base
{
public:
    Base()  { cout << "Base()" << endl;}
    virtual ~Base() { cout << "~Base()" << endl;}
};

class Derived : public Base
{
public:
    Derived()  { cout << "Derived()"  << endl;}
    virtual  ~Derived() { cout << "~Derived()" << endl;}    
};

int main()
{
    Base* p = new Derived; // Derived 객체 생성
    
    // 컴파일러는 p가 Base 라고만 생각.
    delete p;  // 1. p는 Base*이다.
               // 2. 소멸자는 가상함수가 아니다.
}

- 이 예제의 문제점은 Base, Derived에 대한 생성은 정상적으로 이뤄졌지만, p를 Base *로 인식한 것이다.

- 즉, Derived 부분의 자원 회수가 이뤄지지 않았다.

- 아래와 같은 사항이다.

- 해결 방법은 소멸자 호출시 포인터 타입이 아닌 p가 가리키는 메모리를 조사 후 호출해야한다.

- 즉, 기반 클래스(Base)의 소멸자를 가상함수로 한다.

 

기반 클래스의 소멸자는 반드시 가상함수가 되어야 한다.

※ 의도적으로 가상함수로 하지 않는 경우도 있지만, 이는 고급 기법이다.

 

 

 

 

 

 

 

 

 

 

 

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

[C++] Virtual Function Table  (0) 2024.08.22
[C++] Abstract class, Interface  (0) 2024.08.21
[C++] Inheritance(상속)  (0) 2024.08.20
[C++] Copy Constructor  (0) 2024.08.19
[C++] this, Reference return  (0) 2024.08.18
Comments