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인지 확인됩니다.
'C++ 공부' 카테고리의 다른 글
[C++ 기본 1편] 복사 생성자 & 복사 대입 연산자 총 정리 (0) | 2024.02.11 |
---|
댓글