본문 바로가기
C++ 공부

[C++기본 2편] 다형성과 멤버 함수의 특징 & 바인딩 개념 정리

by 문톰 2024. 6. 17.

0. 다형성

- 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질입니다. 마치 카멜레온 처럼

0.1 다형성의 특징

- 같은 인터페이스를 통해 여러 가지 다른 동작을 수행할 수 있다.

- 다형성은 주로 상속과 가상 함수를 통해 구현된다.

 

0.2 다형성의 장점

그렇다면 이런 다형성을 사용하는 이유를 알아보기 위해 어떠한 장점이 있는지 확인해 보겠습니다.

0.2.1  코드 재사용성 향상 유연한 설계

- 다형성을 사용하는 가장 큰 이유는 코드 재사용성 향상과 유연한 설계를 도와줍니다.

- 예시 코드를 통해 확인해보겠습니다.

0.2.2 예시코드 (다형성을 통한 순수 추상 함수 오버라이딩)

#include <iostream>

// 부모 클래스 (Base class)
class Animal {
public:
    // 가상 함수 (Virtual function)
    virtual void makeSound() const {
        std::cout << "Some generic animal sound" << std::endl;
    }

    // 가상 소멸자 (Virtual destructor)
    virtual ~Animal() {}
};

// 자식 클래스 (Derived class) - Dog
class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "Woof! Woof!" << std::endl;
    }
};

// 자식 클래스 (Derived class) - Cat
class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "Meow! Meow!" << std::endl;
    }
};

// 자식 클래스 (Derived class) - Cow
class Cow : public Animal {
public:
    void makeSound() const override {
        std::cout << "Moo! Moo!" << std::endl;
    }
};

int main() {
    // 동물 객체 배열 생성
    Animal* animals[3];

    // 각 포인터를 구체적인 동물 객체로 초기화
    animals[0] = new Dog();
    animals[1] = new Cat();
    animals[2] = new Cow();

    // 다형성을 통해 각 동물의 소리를 출력
    for (int i = 0; i < 3; ++i) {
        animals[i]->makeSound();
    }

    // 메모리 해제
    for (int i = 0; i < 3; ++i) {
        delete animals[i];
    }

    return 0;
}

출력 

Woof! Woof!
Meow! Meow!
Moo! Moo!

 

 

 

 

코드 해석을 통한 장점 이해하기

- Dog, Cat, Cow 클래스는 이제 Animal 클래스라는 리모컨 하나로 관리 가능!

- 즉 다형성을 사용하면 청킹(묶기)을 가능하도록 도와줍니다.

 

 

1. 다형성과  클래스 멤버 함수

- 다형성은 클래스 멤버 함수 호출로부터 시작이 됩니다. 이러한 클래스 멤버 함수의

특징을 확인해 봅시다.

 

 

1. 1 개체 생성 시 한번만 메모리에 할달

- 개체 생성 시 각각의 생성된 개체마다 멤버 함수를 메모리에 할당 하지 않는다.

- 멤버 함수는 컴파일 시 딱 한번만 메모리 영역 중 코드 영역에 할당된다.

==> 저수준에서 멤버함수는 전역 함수와 다르지 않다.

-위의 이미지를 보시면 같은 객체를 가리키는 myCat, yourCat 변수가 있습니다. 

myCat->GetName()
yourCat->GetName()

해당 변수를 사용해 GetName() 멤버 함수를 호출 시, 메모리 영역 중 코드 영역에 저장된 GetName() 함수를 호출합니다.

==> 이를 통해 불필요한 메모리 할당을 줄이는 것을 볼 수 있습니다. 

 

1.2  JAVA의 멤버 함수와의 차이


- JAVA는 virtaul 키워드를 지원하지 않으며 모든 클래스의 멤버 함수는 기본적으로 가상함수로 동작합니다.

반면 C++은 virtual 키워드를 붙여야만 가상 함수이며 그 이외의 함수는 정적 함수로 동작합니다.

 

1.2.1 Virtual의 사용 유무에 따른 차이

1) JAVA

- 다형성을 통해 생성한 객체가 상속 받은 메서드를 호출할 때 무조건 자식 클래스에 구현된 오버라이딩

함수를 호출하게 됩니다.

 

// Parent.java
class Parent {
    void display() {
        System.out.println("Parent's display method");
    }
}

// Child.java
class Child extends Parent {
    @Override
    void display() {
        System.out.println("Child's display method");
    }
}

// Main.java
public class Main {
    public static void main(String[] args) {
        Parent obj = new Child(); // 다형성: 부모 타입 참조로 자식 객체 생성
        obj.display(); // 자식 클래스의 오버라이드된 메서드가 호출됨
    }
}

 

출력 결과

Child's display method

 

 

2) C++

- 자바와는 반대로 virtual 키워드를 붙이지 않으면 부모클래스에 선언된 함수를 호출하게 됩니다. 

#include <iostream>

class Parent {
public:
    virtual void display() { // virtual 키워드 추가
        std::cout << "Parent's display method" << std::endl;
    }
};

class Child : public Parent {
public:
    void display() override { // 오버라이드
        std::cout << "Child's display method" << std::endl;
    }
};

int main() {
    Parent* obj = new Child(); // 다형성: 부모 타입 포인터로 자식 객체 생성
    obj->display(); // 자식 클래스의 메서드가 호출됨
    delete obj;
    return 0;
}

출력 결과

Parent's display method

 

명심할 것

- 함수 override시 반드시 부모 멤버 함수에 virtual 키워드를 붙이기!

 

1.3  virtual 키워드를 붙이면 자식 클래스의 멤버 함수가 호출되는 이유

- 부모 클래스의 멤버 함수에 virtual 키워드를 붙이면 자식 클래스의 멤버 함수가 호출되는 이유를 확인해보겠습니다.

 

1.3.1 컴파일러의 동작 방식

- 컴파일 시 컴파일러는 최적화를 위해 virtual 키워드가 붙여진 함수만 가상 함수 테이블이란 곳에 해당 함수의

주소 값을 저장합니다.

1.3.1 가상 함수 테이블(vtable)이란

- 다형성을 지원하기 위한 중요한 요소 중 하나이며 가상 함수의 주소 값을 저장한 배열입니다.

- 가상 함수가 존재하는 경우 클래스 별로 딱 하나씩 생성됩니다. 

 

1) 가상 함수 테이블 포인터 

- 클래스의 가상 함수가 있는 경우 컴파일러는 해당 클래스의 각 인스턴스에 가상 함수 테이블에 대한

포인터를 생성합니다.

- 즉 객체가 생성될 때마다 컴파일러는 각각의 가상 함수 테이블 포인터를 해당 객체의 메모리 영역에

할당 시킵니다.

2) 가상 함수 테이블의 동작 방식 

- 가상 함수 테이블의 동작 방식은 컴파일 시점, 런타임 시점으로 나눌 수 있습니다.

2-1) 컴파일 시점 동작 순서

1) 가상 함수 테이블 생성 

-클래스에 'virtual' 키워드가 포함된 가상 함수가 있을 경우 가상 함수 테이블을 생성합니다.

-클래스별로 하나씩 생성되며 데이터 or 읽기 전용 데이터 영역에 할당됩니다.

 

*힙 메모리 영역에 저장되지 않는 이유

- 힙은 동적으로 할당되고 해제되는 메모리 영역으로, 런타임에 객체의 인스턴스가 생성되고,

삭제될 때 주로 사용됩니다.

- 해당 vtable은 객체마다 공유하는 데이터이기 때문에 읽기 전용 데이터 영역에 할당됩니다.

 

 

2) vptr(가상 함수 테이블 포인터) 추가

 

3) vtable에 가상 함수 주소 값 저장

-  클래스가 갖고 있는 가상 함수의 주소 값들을 저장합니다.

 

2-2) 런타임 시점

Parent* p = new Child();  // 동적 할당

 

1)  메모리에 vptr이 할당되는 과정

- 힙 메모리 영역에 객체 동적 할당 (정적 할당 시는 스택 혹은 Data 영역)

- 'vptr'이 해당 객체의 힙 메모리 영역 안에 할당( 힙 메모리 => 객체)

 

p->show();  // Child의 show() 호출

 

2) 오버 라이딩 함수 호출 과정

-  힙 메모리에 할당된 Parent 객체를 찾는다.

- 해당 객체의 'vptr'을 통해 해당 클래스의 가상 테이블에 접근한다.

- 가상 테이블에서 호출한 함수의 주소 값을 찾은 후 함수 호출

 

Q1. 가상 함수와 정적 함수중에 어떤 것이 더 빠를까?

- 당연하게도 정적 함수가 더 빠르다.

가상 함수는 가상 테이블을 한번 거쳐서 호출하기 때문입니다.

 

 

3.  C++의 정적 바인딩과 동적 바인딩

*바인딩

- 프로그램이 실행될 때 함수 호출이 실제로 어떤 구현부에 연결되는지를 결정하는 과정입니다.

-이러한 함수의 바인딩에는 정적 바인딩, 동적 바인딩이 있습니다.

3.1 정적 바인딩 

- 컴파일 타임에 어떤 함수가 호출될지를 결정하는 방식입니다. 

3.1.1 정적 바인딩의 특징 

 

1)성능 이점

- 정적 바인딩된 함수는 실행 속도가 빠릅니다. 함수 호출 시 가상 테이블을 통해 주소를 찾을 필요가 없기

때문입니다.

 

2) 유연성 제한

- 동적 바인딩에 비해 유연성이 떨어집니다. 런타임에 객체의 타입에 따라 달라지는 동작 즉 다형성을

지원하지 않습니다.

 

3)정적 바인딩 시 연결되는 함수의 종류

- 전역 함수 (Global functions)

- 비가상 멤버 함수(Non-vritual-member-functions)

- 오버로딩된 함수 (Overloaded functions)

- 정적 함수 (Static functions)

- 템플릿 함수 (Template functions)

 

3.1.2 목적 파일(.o) 생성과 정적 바인딩

-정적 바인딩을 좀 더 구체적으로 설명하자면 목적 파일이 생성될 때 진행되는 바인딩입니다.

1) 바인딩 진행 과정

 - 컴파일러는 main.cpp 파일에서 add라는 함수 발견

 

 1-1) 해당 파일(main.cpp)안에 함수 구현부가 있는 경우

-  컴파일러는 해당 함수의 주소(함수가 메모리에서 위치하게 될 주소)를 심볼테이블에 저장합니다.

- 이 정보는 링커가 나중에 이 함수를 참조할 때 사용하는 정보로 제공됩니다.

- 심볼 테이블에는 해당 함수의 이름과 해당 함수가 위치하는 메모리 주소가 기록됩니다.

// main.cpp
int add(int a, int b) {
    return a + b;
}

 

 

 1-2) 해당 파일(main.cpp)안에 함수의 구현부가 없는 경우

-심볼 테이블에 해당 함수의 이름과 함께 "이 함수는 외부에서 제공된다"는 정보가 저장됩니다.

- 이후 링킹 과정에서 링커가 다른 목적 파일에서 그 함수의 정의를 찾아

연결해야 합니다.

// main.cpp
int main() {
    int result = add(2, 3);  // add 함수는 다른 파일에 정의되어 있다고 가정
    return 0;
}

 

* 심볼 테이블의 역할 정리:

  • 함수 정의가 있는 경우: 심볼 테이블에 함수의 이름과 해당 함수의 주소가 기록됩니다. 이 주소는 함수의 실제 구현을 참조할 수 있도록 링커가 사용합니다.
  • 함수 정의가 없는 경우: 심볼 테이블에 함수의 망글링화된 이름과 이 함수는 외부에서 정의될 것이라는 정보가 포함됩니다. 링커가 나중에 이 함수의 정의를 다른 파일에서 찾고, 그 파일과 연결해 주소를 할당합니다.

* 번외: 목적 파일에서 심볼 테이블에 정의된 함수 찾기

// add.cpp
int add(int a, int b) {
    return a + b;
}

int minus(int a, int b) {
    return a - b;
}

int multiple(int a, int b) {
    return a * b;
}

- 위와 같이 add.cpp 파일에 add, minus, multiple이 정의된 경우

// 목적 파일 생성
g++ -c add.cpp -o add.o

// nm 명령어로 심볼 테이블 정보 확인
nm add.o

 - nm명령어를 통해 심볼 테이블 내부 정보를 확인할 수 있습니다. 

 

예상 출력:

00000000 T _Z3addii
00000020 T _Z5minusii
00000040 T _Z8multipleii

 

해석:

  • 00000000: 각 심볼이 목적 파일 내에서 시작하는 오프셋 주소를 나타냅니다.
  • T: 이 심볼이 텍스트 섹션(즉, 함수 코드 영역)에서 정의되었음을 나타냅니다. 이는 해당 함수가 이 목적 파일 안에 정의되어 있음을 의미합니다.
  • _Z3addii: add(int, int) 함수의 망글링된 이름입니다.
  • _Z5minusii: minus(int, int) 함수의 망글링된 이름입니다.
  • _Z8multipleii: multiple(int, int) 함수의 망글링된 이름입니다.

 

3.2 동적 바인딩

- 런타임에 어떤 함수가 호출될지를 결정하는 방식입니다.

- 다형성을 지원하기 위해 사용됩니다.

- 위의 1.3에서 설명했듯이 동적 바인딩은 가상 함수 테이블, 가상 함수 포인터, 가상 함수 등을 사용해

동작합니다. 

 

3.2.1 동적 바인딩 과정

1) 컴파일 타임에서의 동작 과정

- 컴파일 타임에서 virtual 키워드가 붙인 함수가 있는 경우

아래와 같은 준비 과정이 진행됩니다.

 

 1-1) 가상 함수 포인터 생성 

 - Heap 영역에 가상 함수 포인터를 메모리에 할당

- 생성된 객체의 개수만큼 메모리에 할당

 

 1-2) 가상 함수 테이블 생성

- 클래스당 딱 1개만 Data 영역에 가상 함수 테이블의 정보를 저장

- 해당 함수 테이블에는 가상 함수의 주소 값이 저장

 

2) 런타임에서 가상 호출 시 동작 과정

- 런타임에서 가상 함수 테이블에 저장된 함수 호출 시 아래 과정이 진행됩니다.

2-1) 가상 함수 호출

2-2) 가상 함수 포인터로 가상 함수 테이블 참조

 

2-3) 원하는 가상 함수의 주소 값 조회

2-4) 가상 함수 호출

 

3.1.2 왜 다형성을 지원하기 위해선 동적 바인딩이 필요할까?

- 다형성의 본질은 바로 런타임에 객체의 실제 타입에 따라 적절한 함수를 호출입니다.

-이를 통해 다양한 객체가 하나의 공통된 인터페이스를 사용해 유연하게 동작할 수 있도록 합니다.

- 그럼으로 정적 바인딩을 사용 시 아래와 같은 상황에 대비 할 수 있습니다.

 

1-1) 런타임 입력에 따라 객체 타입이 결정되는 경우

#include <iostream>

class Animal {
public:
    virtual void sound() {
        std::cout << "Animal makes a sound" << std::endl;
    }
};

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

class Cat : public Animal {
public:
    void sound() override {
        std::cout << "Cat meows" << std::endl;
    }
};

int main() {
    Animal* animal = nullptr;
    int choice;

    std::cout << "Choose an animal (1 = Dog, 2 = Cat): ";
    std::cin >> choice;

    // 런타임에 사용자가 선택한 동물 객체를 생성
    if (choice == 1) {
        animal = new Dog();
    } else if (choice == 2) {
        animal = new Cat();
    } else {
        animal = new Animal();  // 기본적으로 Animal 객체를 생성
    }

    // 런타임에 객체의 실제 타입에 따라 적절한 함수가 호출됨
    animal->sound();

    // 메모리 해제
    delete animal;

    return 0;
}

 

-  animal->sound() 함수 호출 시 컴파일 타임에선 animal 객체가 Dog인지 Cat인지 알 수 없습니다.

- 이 때 동적 바인딩을 통해 런타임에서 호출해야 하는 객체의 타입이 결정됩니다.

 

 

1-2) new로 선언된 동적 할당의 경우

#include <iostream>
#include <vector>

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

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

class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a rectangle" << std::endl;
    }
};

int main() {
    // Shape 클래스의 포인터 배열
    std::vector<Shape*> shapes;
    
    // 런타임에 서로 다른 타입의 객체들을 저장
    shapes.push_back(new Circle());
    shapes.push_back(new Rectangle());

    // 반복문을 통해 다형성으로 각각의 실제 객체 타입에 맞는 함수를 호출
    for (Shape* shape : shapes) {
        shape->draw();  // 동적 바인딩을 통해 적절한 함수 호출
    }

    // 메모리 해제
    for (Shape* shape : shapes) {
        delete shape;
    }

    return 0;
}

 

언뜻 보기에 코드 상에 new Circle, new Rectangle이라고 적혀 있기 때문에 컴파일 타임에서 충분히 예측할 수

있다고 판단할 수 있습니다.  하지만 컴파일러의 눈에는 해당 코드는 다르게 해석됩니다. 즉 Circle, Rectangle은 각각

Shape 객체(Shape*)로 해석이 됩니다. 이후에 런타임에서 동적 바인딩을 통해 해당 포인터가 가리키는

객체가 Circle인지 Rectangle인지 확인됩니다.

 

 

 

 

 

 

 

 

 

 

 

댓글