Eat Study Love

먹고 공부하고 사랑하라

SW 만학도/C++

8. Inheritance - Basics

eatplaylove 2024. 4. 12. 16:00

https://eglife.tistory.com/51

 

7. Copy&Move, Special Members

https://eglife.tistory.com/50 6. Out_of_class Definition & Operator Overloading https://eglife.tistory.com/49 5. Classes https://eglife.tistory.com/43 4. Functions and Memory Management https://eglife.tistory.com/36 3. C++ Standard Library (3) https://egli

eglife.tistory.com

 

요번엔 C++ Class 상속 Inheritance에 관한 얘기다.

 

내용이 딱딱한만큼 예시는 말캉한 포켓몬스터로 들어서 밸런스를 좀 조절해야지 ㅋ

 

 

 

일단, 저번시간 배운 / 오늘 배울 내용 KEYWORD 간단히 정리

 

- Previous :

  Basic Structure, Class template, Out-Of-Class Definition, Operator Overloading, Copy and Move Semantics, Static member

 

- Overview :

 Inheritance - Basics, Class substitution, Dynamic binding, Multiple inheritance, Multi-Level inheritance, Special classes, Type casting, Exception handling

 

 

Class Design

귀요미들~ 다 때려치우고 나도 포켓몬세상에서 살고싶다.. 거기도 스트레스가 있을라나 ㅋㅋ

 

 

- 자세히보면 피카츄, 파이리 class에는 공통적인 부분이 있다.

 

#include <string>
#include <iostream>

class Pokemon{
protected:
    std::string name; // 피카츄, 파이리
    int hp; // 0 , 1 , ... , 풀피 100
    enum Type {ELECTRIC, FIRE} type; //enum --> 임의의 값

public:
    Pokemon(std::string name, int hp, Type type) : name(name),hp(hp),type(type){}

    int getHp(){
        return hp;
    }

    std::string getName(){
        return name;
    }

    Type getType(){
        return type;
    }

    void decreaseHp(int amount){
        hp = hp - amount;
    }

    void attack(Pokemon& opponent){
        std::cout << "[BASE]: "<<this->name<<"attacks " << opponent.getName()<<std::endl;}

};

 

 

- enum 열거형을 정의하는 키워드로 사용된다. 즉, enum Type {Elec, Fire} 에서 Type은 열거형의 이름이고 Elec과 Fire는 그 열거형에 속하는 값이다.

※ Type이라는 이름은 사용자가 임의로 정해도 된다. enum asdnasjiodn {A,B} 이렇게 해도 문제X

 

- enum에선 internally하게 열거형의 각 값들은 숫자로 할당이 된다. (ex, electric -> 0 , fire -> 1 ...)

 

- Protected members --> 본인 + 본인을 상속한 Class에서 접근이 가능한 것들이다.

 

 

- 아래 Constructor 부분 --> attribute를 initialize 해준다.

Pokemon(std::string name, int hp, Type type) : name(name),hp(hp),type(type){}

 

- 아래 Getters 부분

    int getHp(){
        return hp;
    }

    std::string getName(){
        return name;
    }

    Type getType(){
        return type;
    }

 

 

 

- Attack의 객체 opponent는 Pokemon class' object 이거나 그것을 상속받은 class의 object이다.

 

- opponent의 경우 call by reference로 해서 memory leak을 막자. 요건 뭐 누누히 말했던 부분~

 

 

Derived Classes ( 클래스 상속! )

// Derived Classes
class Pikachu : public Pokemon{ // 요렇게 inherit 하는구나
    int electricLevel;
public :
Pikachu() :Pokemon("Pikaka",100,ELECTRIC),electricLevel(10){}
    ~Pikachu(){} //Release!!

    void attack(Pokemon& opponent){
        if(opponent.getType()==FIRE){
            opponent.decreaseHp(electricLevel-2);
        }
        else{
            opponent.decreaseHp(electricLevel);
        }
    }

    void cry(){
        std::cout<<"cry cry cry"<<std::endl;
    }
};

int main()
{   std::cout << "code start! " << std::endl;

    return 0;
}

 

 

- 위 코드와 같이 실행했을 때, 피카츄 클래스는 포켓몬 클래스를 상속했다고 표현한다.

( = inherits from, is derived from, extends, is a subclass of ... )

 

- 자, 이 때 그림의 public에 주목해보자. public Pokemon은 무엇을 의미할까?

 

   1. Pikachu class는 Pokemon class의 public 부분은 public으로 상속받고, protected 부분은 그대로 protected Level로 상속받는다는 것이다. Pokemon class에 만약 private level이 있다면, 당연히 그 부분은 상속받지 못한다.

 

   2. 만약 : protected Pokemon 으로 했다면? Pokemon class의 access Level의 attribute, method를(Private 제외) protected Level로 지정해서 상속받는다는 것이다.

 

   3. 그렇다면 자연스럽게, : private Pokemon은 그것들을 private level로 지정해서 받는다는 것!

 

class Pikachu : public Pokemon{
    int electricLevel;
    .
    .
    .
};

 

 

- 위 int electricLevel과 같이 access specifier을 더해주지 않은 members들은 자동으로 private Level로 분류된다(default)

 

- 요 부분에 name, type 등등 attribute를 추가적으로 선언할 순 있다. 그렇지만 권고사항은 아니다. same name의 variable이 2개 생겨서 이상동작이 발생할 수 있기 때문 ! . . . .정확히 무슨 소리지,,?

 

--> 아 Base Class(Pokemon Class)에 정의되었던 name, hp 등등이 Derived Class에서 재정의 될 수도 있지만, 그것을 권고하진 않는다는 뜻이군 --> 그 순간 name, hp variable은 separate하게 같은 이름이지만 각 각 2개로 쪼개지는 것이다.

 

 

 

- 자 이 Constructor 부분은 좀 유의깊게 보아야 하는 부분이다.

 

- Base class인 Pokemon class에 대한 호출은 initializer list에서만 가능하다. 그 뒤에 따라오는 body { } 부분에서 하면 안 된다.

 

- 이 경우에 위와 같이 Base 부분 body가 { } 로 비어있는 것이다. 만약에 이게 이뤄지지 않는다면 Compiler는 Base class의 default constructor를 내부적으로 call하게 되고 base class에 인자를 받지 않게 된다.

 

 

※ 관련된 추가정보

1. Initializer list과 Body

  • Initializer list는 생성자의 서명과 중괄호 사이에 위치하며, 콜론 (:) 다음에 나옵니다. 이 리스트는 생성자 본문이 실행되기 전에 멤버 변수와 상속받은 클래스의 생성자를 초기화하는데 사용됩니다. 여기서 :Pokemon("Pikaka", 100, ELECTRIC), electricLevel(10) 부분이 initializer list에 해당합니다.
  • Body는 생성자의 중괄호 ({}) 안에 위치하며, 이는 초기화 리스트 이후에 실행되는 코드를 포함합니다. 여기서 본문은 빈 상태입니다 ({}).

2. Base Class Call을 Initializer List에 포함하지 않을 때

  • C++에서 생성자의 initializer list에 기반 클래스의 생성자 호출을 포함하지 않을 경우, 컴파일러는 기반 클래스의 기본 생성자를 자동으로 호출하려고 시도합니다. 만약 기반 클래스에 명시적인 기본 생성자가 없고, 다른 생성자만 정의되어 있다면, 컴파일러는 에러를 발생시킵니다. 즉, Pokemon 클래스에 기본 생성자가 없으면, Pokemon("Pikaka", 100, ELECTRIC)를 명시적으로 호출하지 않으면 컴파일 에러가 발생합니다.

3. 멤버 변수 int electricLevel의 Body에서의 선언 ( electricLevel 정도는 body에 사용 가능? )

  • 멤버 변수의 초기화를 생성자 본문에서 수행할 수도 있습니다. 그러나 이는 초기화 리스트를 사용하는 것보다 덜 효율적일 수 있습니다. 초기화 리스트는 객체 생성과 동시에 멤버를 초기화하지만, 본문에서 할당하는 방식은 멤버가 먼저 기본으로 초기화된 후 다시 할당되는 과정을 거치기 때문입니다.

     class Pikachu : public Pokemon { int electricLevel; public: Pikachu() : Pokemon("Pikaka", 100, ELECTRIC) 
     { electricLevel =
    10; // 본문에서 초기화 } }; 

    위와 같이 쓰면, Pikachu 객체가 생성될 때 electricLevel은 본문에서 10으로 설정됩니다. 그러나 일반적으로는 데이터 멤버를 초기화 리스트에서 초기화하는 것이 좋은 습관입니다.

 

 

- 위의 예시의 경우 Pokemon class에는 default 생성자가 명시적으로 정의되어 있지 않아서 가이드를 따르지 않으면 Error가 발생한다.

 

 

- Base Class의 destructor 까지는 상속이 되지 않는다.

 

 - Pikachu class의 object가 삭제되면, 일단 피카츄 class destructor가 먼저 call 되고 그 뒤에 포켓몬 class destructor가 순차적으로 call 된다.

 

- Base / Inherited Class의 Constructor & Destructor call 다이어그램은 아래와 같다.

 

 

 

- Construct의 경우 부모 --> 자식 순, Destruct의 경우 자식 --> 부모 순으로 Call한다.

 

눈물겨운 전기와 불의 싸움

 

- Derived class에는 추가적인 method가 구현이 가능하다.

 

 - 참고로, 위에서 언급했듯이 Attribute(name, hp, type...) 같은 경우는 base에 있는 걸 derived에 그대로 overwrite하는 건 권고하진 않지만 Method의 경우는 가능하며, 실제로 필드에서 많이 발생하는 현상이다.

( ex) attack method 수정 in Pikachu class)

 

- 피카츄 특유의 void cry() method도 class 내부에 declare 해줬는데, 이 역시 가능가능가능하다!

 

- 싸움을 붙여보기 위해서 파이리도 만들어보자.

 

// Derived Classes 2 -> Charmander

class Charmander : public Pokemon{
    int flameLevel; // 불꽃레벨은 default private LV로 가져간다는 것

public:
    Charmander() : Pokemon("Pairi",200,FIRE),flameLevel(5){}

    void attack(Pokemon& opponent){
        if(opponent.getType()==ELECTRIC){
            opponent.decreaseHp(flameLevel+2);
        }
        else{
            opponent.decreaseHp(flameLevel);
        }
    }
};

 

 

- 피카츄 , 파이리 가지고 Main 함수에서 좀 놀아보자

 

int main()
{   std::cout << "code start! " << std::endl;

    Pikachu pik;
    Charmander pai;
    // class object declare

    std::cout << "Pick HP: "<< pik.getHp() << std::endl;
    std::cout << "Pairi HP: "<< pai.getHp() << std::endl;

    pik.attack(pai); // 피카츄가 파이리를 공격!

    std::cout << "Pick HP: "<< pik.getHp() << std::endl;
    std::cout << "Pairi HP: "<< pai.getHp() << std::endl;

    pai.attack(pik); // 파이리의 복수

    std::cout << "Pick HP: "<< pik.getHp() << std::endl;
    std::cout << "Pairi HP: "<< pai.getHp() << std::endl;
/*
    code start! 
    Pick HP: 100
    Pairi HP: 200
    Pick HP: 100
    Pairi HP: 192
    Pick HP: 93
    Pairi HP: 192
    cry cry cry

    ==> attack을 했을 때 base class에 대한 attack COUT은 call되지 않는다!!!
*/

    pik.cry();
    return 0;
}

 

 

- 중점적으로 봐야할 것은 base case에 정의된 attack에 대한 cout은 출력되지 않는다는 것

 

- 이는 Overwrite(덮어쓰기) 때문이다. 피카츄/파이리 class에 이미 attack 이라는 같은 이름의 함수가 덮어쓰기 형태로 선언 되었기 때문에 Base case인 Pokemon class의 attack은 call하지 않는 것이다.

 

- 만약 피카츄 or 파이리에 attack이라는 method가 선언되지 않았다면, Pokemon class에 있던 attack method가 call된다.

 

Pick HP: 100
Pairi HP: 200
[BASE]: Pikaka  attacks Pairi
Pick HP: 100
Pairi HP: 200

// --> 이 경우 base class에서 attack에 체력깎는 것을 지정 안 했으니 피카츄,파이리 체력변화는 없고
// base class attack methoddp 있던 print문만 출력이 된다.

 

 

- 만약 피카츄 / 파이리 class 내부 attack method에 base class의 attack method를 덧입히고 싶다면 아래와 같이 코드를 짜볼 수 있다.

 

Pikachu() :Pokemon("Pikaka",100,ELECTRIC),electricLevel(10){ }

    ~Pikachu(){} //Release!!

    void attack(Pokemon& opponent){

        Pokemon::attack(opponent); // 추우가!

        if(opponent.getType()==FIRE){
            opponent.decreaseHp(electricLevel-2);
        }
        else{
            opponent.decreaseHp(electricLevel);
        }
    this->decreaseHp(1); // 공격할 때마다 본인 체력도 1씩 깎인다고 추가해봤음

    }
/*
code start! 
Pick HP: 100
Pairi HP: 200
[BASE]: Pikaka  attacks Pairi
Pick HP: 99
Pairi HP: 192
*/

 

 

Inheritance Exceptions

 

- 아래 4가지의 경우 Base -> Inherited class로 상속되지 않는 부분이다.

아,, 상속할거면 다 받지 부분상속 무엇 ㅠ

 

 

Memory Layout

포켓몬(Base Class), 피카츄 / 파이리(Inherited Class) Memory Layout

 

 

- padding은 compiler의 optimiztation을 위한 공간(4byte)으로 추정한다.

 

- Class가 define 되면 위와 같이 Class 내부에서 attribute가 선언된 순서대로 memory block이 연속적으로 쌓여간다.

 

- Child Class의 경우 Parents Class와 구성이 같고, 추가적으로 각 각 선언된 attribute의 Memory가 쌓인다.

 

- 이 기본적인 Memory 구조는 Virtual pointer(좀 있다가 다룰 것..) / Padding이라는 특수 공간을 갖을 수 있다.

 

- 이는 Compiler마다 layout이 달라질 수도 있다는 것..! C++ std에 의해 고정된 사항이 아니다.

 

 

Class Substitution

 

- 객제지향프로그래밍(Object Oriented Programming)을 위한 기본 컨셉이다. Derived Class에서 만들어진 object가 Base Class에서 만들어진 object처럼 취급될 수 있게 한다.

 

- Base Class를 가르키는 Pointer는 그것의 Derived Class를 가르킬 수 있고, base class 내부에 정의된 public member들에 접근할 수 있다.

 

Pokemon* createPokemon(std::string name){
    if (name == "Pikaka"){
        return new Pikachu();
    }
    else if(name == "Pairi"){
        return new Charmander();
    }
    else{return nullptr;}
}

int main()
{   std::cout << "code start! " << std::endl;

    Pokemon *poke1, *poke2; // 각각 포인터 * 표시 해줘야 함.
    std::string pokename1, pokename2;

    std::cout << "Enter name for poke1: (Pikaka or Pairi) ";
    std::cin >> pokename1;
    poke1 = createPokemon(pokename1);
    if(poke1 == nullptr){return 0;}
    
    std::cout << "Enter name for poke2: (Pikaka or Pairi) ";
    std::cin >> pokename2;
    poke2 = createPokemon(pokename2);
    if(poke2 == nullptr){return 0;}
    
    // class 포인터 check

    std::cout << "POKE1 : " << (*poke1).getName() << std::endl;
    // std::cout << "POKE1 : " << (*poke1).name << std::endl; //얘도 가능, 만약에 name attribute가 Pokemon class의 public에 있다면
    std::cout << "POKE2 : " << poke2->getName() << std::endl;
    
    //OUTPUT
    /*code start! 
    Enter name for poke1: (Pikaka or Pairi) Pikaka
    Enter name for poke2: (Pikaka or Pairi) Pairi
    POKE1 : Pikaka
    POKE2 : Pairi

 

 

- createPokemon 함수는 Pokemon*, 즉 base class를 가르키는 포인터를 반환한다. base class를 가르키기에, 그것의 derived class역시 가르킬 수 있는 것이다.

 

- cry() 같은 피카츄만의 method는 위 포인터로는 call할 수 없다.

 

- 그리고 아래와 같이 attack()을 한다면 역시, 피카츄 class의 attack이 아닌 Base Class의 Attack이 Call 된다.

 

poke1 -> attack(*poke2);
//POKE1 : Pikaka
//POKE2 : Pairi
//[BASE]: Pikaka  attacks Pairi

 

- 이 상황을 타개할 수 있는 것은 무엇일까? ↓ ↓ ↓ ↓ ↓ ↓ ↓

 

 

Virtual Function ( Base 말고 하위 class안의 Method를 쓰고 싶엉! )

virtual 과 override는 세트이다

 

- Base Class의 virtual 표시는 해당 member function이 Derived Class에서 override 될 수 있다는 것을 의미한다.

( Virtual ---> Base Class --> Polymorphic(다형성, 동일한 이름의 Method가 서로 다른 기능하는 걸 허용) ↑ ,

Override --> Derived Class --> Compiler에게 Base Class의 virtual 함수를 override 한다고 귀띔주는 것)

 

    std::cout << "POKE1 : " << (*poke1).getName() << std::endl;
    std::cout << "POKE2 : " << poke2->getName() << std::endl;
    poke1 -> attack(*poke2);
    std::cout << "HP check : poke1 - " << poke1->getHp() << " poke2 - " <<poke2->getHp()<<std::endl;
// code start! 
// Enter name for poke1: (Pikaka or Pairi) Pikaka
// Enter name for poke2: (Pikaka or Pairi) Pairi
// POKE1 : Pikaka
// POKE2 : Pairi
// HP check : poke1 - 99 poke2 - 192

 

 

- Virtual과 Override를 더해주니, attack 동작이 피카츄 class에 있는대로 잘 동작한다.

 

- 참고로 Pikachu class에 override 표시 없이 Pokemon class의 attack method에만 virtual 표시를 해줬는데도 똑같이 동작은 하더라..

 

Virtual for Destructor

 

 

- Destructor를 할 때도 virtual + override 을 해주는 게 관례다

 

- 그렇지 않고 Base Class Pointer로 Derived class의 destructor를 call하면 Derived class의 destructor를 무시하고 바로 BBase Class의 Destructor를 call한다.

 

 

Function Overriding vs Overloading

비슷한듯 다른듯 비슷

 

 

- Overriding : 상위 class와 달리 하위 class에서 method 재정의를 하고 싶을 때 사용

 

- Overloading : 같은 Scope 안에 method가 여러 개 정의되어 있을 때, 인자를 다르게 넣고 싶으면 사용

(인자만 다르면 같은 이름의 함수를 여러 개 쓰게 해준다.)

 

 

- 이와 관련된 추가설명 by GOD Gpt

함수 Overloading (함수 중복)

함수 오버로딩은 같은 범위(주로 같은 클래스 내)에 같은 이름의 함수를 여러 개 정의할 수 있게 하는 기능입니다. 오버로딩된 함수들은 매개변수의 타입(attribute), 개수, 순서가 서로 달라야 합니다. 반환 타입은 오버로딩을 결정하는 데 사용되지 않습니다.(반환 타입은 서로 같거나 달라도 상관 없다) 오버로딩은 주로 같은 작업을 수행하지만, 다른 타입이나 인자의 수에 따라 다르게 동작해야 할 때 사용됩니다.

당연하겠지만, onlt 반환타입만 다를 경우엔 Overroading이 되지 않는다.

ex)



여기서 print 함수는 세 가지 버전으로 오버로드되어 있으며, 인자의 타입에 따라 다른 동작을 수행합니다.


함수 Overriding (함수 재정의)

함수 오버라이딩은 파생 클래스에서 기반 클래스의 멤버 함수를 새로운 동작으로 대체하는 기능입니다. 오버라이딩은 상속 관계에서만 일어나며, 파생 클래스에서 기반 클래스의 함수를 재정의할 때 사용됩니다. 오버라이딩된 함수는 기반 클래스의 함수와 동일한 이름, 매개변수 타입 및 개수, 반환 타입을 가져야 합니다. --> 함수 동작만 수정해주는 것이구만!!


ex)



여기서 Dog 클래스는 Animal 클래스의 speak 함수를 오버라이딩하여, 동일한 함수 이름(speak)을 사용하지만 "Woof!"를 출력하도록 변경합니다.

  • 오버로딩은 같은 이름의 함수를 여러 형태로 정의하는 것을 가능하게 해서, 다양한 입력 타입에 대응할 수 있게 합니다.
  • 오버라이딩은 상속 구조에서 파생 클래스가 기반 클래스의 특정 함수를 대체하여, 다형성(polymorphism)을 구현하는데 사용됩니다.
이 두 기능은 C++의 유연성과 강력한 타입 체계를 활용하여, 코드의 재사용성과 유지 보수성을 향상시키는 데 중요한 역할을 합니다.


 

 

Dynamic Binding

 

 

- 함수가 pointer or reference를 통해 call 되었을 때, 어떤 함수가 RUNTIME에 불리울 지 결정해주는 과정

 

- 이 때 등장하는 개념이 Virtual Pointer(vptr)Virtual Table(vtable)이다.

 

virtual & override를 쓰면 자동으로 vptr / vtable이 생긴다고 이해

 

- vtable : Class의 모든 가상함수(Virtual Function)의 주소를 저장하는 테이블이다. Object가 Virtual Function을 Call할 때, 어느 Fucntion을 Call 할 지 결정해준다.(= Array of Function Poiner로 저장되어있다.) 그리고 vtable은 each Class마다 형성이 되어있다.

 

- 그렇네, 피카츄/파이리 Class에는 useItem method가 없는데도 vtable은 다 만들어져있네..

( 그럼에도 불구하고 userItem이 call되었을 때, Pokemon에 있는 useItem을 쓰라고 arrange해주는 역할이 필요해서 그런갑다)

 

- Override가 없는 method의 경우 하위 class는 상위 class의 method를 그대로 가져다 쓴다.

 

- vptr은 각 Class의 Memory 상 1번째에 위치한다.

 

- 요 전체적인 메커니즘을 Dynamic Binding이라고 한다.

 

 

Dynamic Binding at Compile Time & Runtime

 

 

 

- vtable 만들고, vptr 만들고, vptr은 vtable 가르키고~

 

 

 

 

- virtual function call되면 vptr dereference 되고, vatable을 거쳐 적절한 function pointer로 반환된 후에~ 그 포인터가 어떤 function을 택해야 하는지 달려가서 콕 찍는다. also like dereference

 

 

 

- 같은 function call을 해도 object마다 respond는 다를 수 있다 --> Polymorphism(다형성)

 

-  CompileTime -> Static , Runtime -> Dynamic

 

VPTR 은 모든 class의 메모리 첫 번째에 자리잡다.

 

- Virtual function / override를 사용했다면, 자동으로 Vptr / Vtable이 Compile 시 모든 CLASS에 형성될 것이고, 이 때 vptr은 각 class memory에서 제일 먼저 형성된다.

(이 와중에 Vptr도 pointer라 memory 공간은 8byte 차지한다. 어떠한 종류의 type을 가르키는 pointer라도 메모리공간을 8byte인 것 재확인 ㅎㅎ)

 

 


 

이상, 금일의 대장정 끄읕!