C언어 (10)
1. 부모·자식 클래스 사이의 변환
상속 관계에서 부모 클래스와 자식 클래스 사이에는 타입 변환 규칙 이 존재한다. 이 규칙을 이해하는 것이 다형성의 출발점이다.
자식 → 부모 (업캐스팅)
자식 클래스 객체는 부모 클래스 타입으로 자연스럽게 변환 된다. 이것을 업캐스팅(Upcasting) 이라 한다.
class Animal {
public:
void breathe() {
cout << "숨을 쉰다" << endl;
}
};
class Dog : public Animal {
public:
void bark() {
cout << "멍멍!" << endl;
}
};
int main() {
Dog dog;
Animal *pAnimal = &dog; // 업캐스팅 — 자식을 부모 포인터로
Animal &rAnimal = dog; // 레퍼런스로도 가능
pAnimal->breathe(); // OK
// pAnimal->bark(); // 에러! — 부모 타입이므로 자식 고유 멤버 접근 불가
return 0;
}
Dog은 Animal을 상속받았으므로 Animal이 가진 모든 것을 포함하고 있다. Dog 객체를 Animal *로 가리켜도 Animal 부분은 온전히 존재하기 때문에 안전하다. 다만 부모 타입 포인터로는 부모가 가진 멤버만 접근 할 수 있다. 자식 고유의 bark()는 호출할 수 없다.
업캐스팅은 암시적(자동) 으로 일어난다. 캐스팅 연산자를 쓸 필요가 없다.
부모 → 자식 (다운캐스팅)
반대로 부모 타입을 자식 타입으로 변환하는 것을 다운캐스팅(Downcasting) 이라 한다. 이것은 위험할 수 있어서 명시적 캐스팅이 필요 하다.
Animal *pAnimal = new Dog(); // 업캐스팅
Dog *pDog = (Dog *)pAnimal; // 다운캐스팅 — 명시적 캐스팅 필요
pDog->bark(); // OK — 실제로 Dog 객체이므로 안전
위 경우는 pAnimal이 실제로 Dog 객체를 가리키고 있으므로 안전하다. 하지만 실제로 Animal 객체를 가리키는 포인터를 Dog *로 다운캐스팅하면 존재하지 않는 멤버에 접근하게 되어 프로그램이 비정상 동작한다.
Animal *pAnimal = new Animal();
Dog *pDog = (Dog *)pAnimal; // 위험! 실제로는 Animal 객체
pDog->bark(); // 정의되지 않은 동작
안전한 다운캐스팅을 위해 C++에서는 dynamic_cast를 제공한다. 실제 타입이 맞지 않으면 포인터의 경우 nullptr을, 레퍼런스의 경우 예외를 반환한다.
Animal *pAnimal = new Dog();
Dog *pDog = dynamic_cast<Dog *>(pAnimal);
if (pDog != nullptr) {
pDog->bark(); // 안전하게 호출
} else {
cout << "변환 실패" << endl;
}
dynamic_cast는 런타임에 실제 타입을 검사하므로, 부모 클래스에 최소 하나의 virtual 함수 가 있어야 사용할 수 있다.
객체 슬라이싱
업캐스팅을 포인터나 레퍼런스가 아닌 값으로 하면 문제가 생긴다.
Dog dog;
Animal a = dog; // 객체 슬라이싱 발생!
Dog 객체를 Animal 변수에 값으로 대입하면, Dog에만 있는 멤버가 잘려나간다. Animal 크기만큼만 복사되기 때문이다. 이것을 객체 슬라이싱(Object Slicing) 이라 한다. 그래서 다형성을 활용할 때는 반드시 포인터 또는 레퍼런스 를 사용해야 한다.
2. 다형성
다형성(Polymorphism)은 같은 인터페이스가 상황에 따라 다르게 동작하는 것 이다. C++에서 다형성을 구현하는 두 가지 방법이 바로 오버로딩 과 오버라이딩 이다.
2-1. 오버로딩이 보여주는 다형성
오버로딩은 같은 이름의 함수가 매개변수에 따라 다르게 동작 하는 것이다. 함수 이름이라는 하나의 인터페이스 뒤에 여러 구현이 숨어있다.
void print(int x) {
cout << "정수: " << x << endl;
}
void print(double x) {
cout << "실수: " << x << endl;
}
void print(string x) {
cout << "문자열: " << x << endl;
}
int main() {
print(42); // 정수: 42
print(3.14); // 실수: 3.14
print("Hello"); // 문자열: Hello
return 0;
}
print라는 같은 이름을 호출하지만, 넘기는 인자의 타입에 따라 다른 함수가 실행된다. 이 결정은 컴파일 타임 에 이루어진다. 컴파일러가 인자의 타입을 보고 어떤 함수를 호출할지 미리 결정하기 때문이다.
2-2. 오버라이딩이 보여주는 다형성
오버라이딩은 부모의 함수를 자식이 재정의하여, 실제 객체에 따라 다르게 동작 하는 것이다.
class Shape {
public:
virtual void draw() {
cout << "도형을 그린다" << endl;
}
};
class Circle : public Shape {
public:
void draw() override {
cout << "원을 그린다" << endl;
}
};
class Rectangle : public Shape {
public:
void draw() override {
cout << "사각형을 그린다" << endl;
}
};
int main() {
Shape *shapes[3];
shapes[0] = new Shape();
shapes[1] = new Circle();
shapes[2] = new Rectangle();
for (int i = 0; i < 3; i++) {
shapes[i]->draw(); // 실제 객체에 따라 다른 함수 호출
}
// 출력:
// 도형을 그린다
// 원을 그린다
// 사각형을 그린다
for (int i = 0; i < 3; i++) {
delete shapes[i];
}
return 0;
}
shapes 배열은 전부 Shape * 타입이지만, 실제 가리키는 객체가 Circle인지 Rectangle인지에 따라 다른 draw()가 호출된다. 이 결정은 런타임 에 이루어진다. 프로그램이 실행되면서 실제 객체의 타입을 확인하고 적절한 함수를 호출하기 때문이다.
오버로딩과 오버라이딩 모두 "같은 이름, 다른 동작"이라는 다형성의 본질을 보여주지만, 결정 시점이 다르다. 이 차이를 더 정확하게 설명하는 개념이 바로 정적 결합 과 동적 결합 이다.
3. 동적 결합과 정적 결합
"결합(Binding)"이란 함수 호출 코드가 실제로 실행될 함수의 주소와 연결되는 것 을 말한다. 이 연결이 언제 이루어지느냐에 따라 정적 결합과 동적 결합으로 나뉜다.
3-1. 정적 결합 (Static Binding)
정적 결합은 컴파일 타임 에 호출할 함수가 결정되는 것이다. Early Binding 이라고도 한다.
일반 함수 호출, 오버로딩된 함수 호출, 그리고 virtual이 아닌 멤버함수 호출이 여기에 해당한다.
class Animal {
public:
void speak() { // virtual 아님
cout << "..." << endl;
}
};
class Dog : public Animal {
public:
void speak() { // 재정의했지만 virtual이 아님
cout << "멍멍!" << endl;
}
};
int main() {
Dog dog;
Animal *p = &dog; // 업캐스팅
p->speak(); // "..." 출력 — Animal의 speak()이 호출됨!
return 0;
}
p의 타입은 Animal *이다. speak()에 virtual이 없으므로, 컴파일러는 포인터의 타입만 보고 Animal::speak()를 호출하기로 컴파일 시점에 결정 한다. 실제로 Dog 객체를 가리키고 있어도 무관하다.
정적 결합은 빠르다. 컴파일 타임에 함수 주소가 확정되므로 런타임에 추가적인 탐색 비용이 없다.
3-2. 동적 결합 (Dynamic Binding)
동적 결합은 런타임 에 호출할 함수가 결정되는 것이다. Late Binding 이라고도 한다. virtual 키워드가 이것을 가능하게 한다.
class Animal {
public:
virtual void speak() { // virtual 함수
cout << "..." << endl;
}
};
class Dog : public Animal {
public:
void speak() override {
cout << "멍멍!" << endl;
}
};
int main() {
Dog dog;
Animal *p = &dog;
p->speak(); // "멍멍!" 출력 — Dog의 speak()이 호출됨!
return 0;
}
virtual을 붙이면 컴파일러는 포인터의 타입이 아니라 실제 객체의 타입 을 기준으로 함수를 호출한다. p의 타입은 Animal *이지만 실제로 Dog 객체를 가리키고 있으므로 Dog::speak()가 호출된다.
이것이 가능한 이유는 가상 함수 테이블(vtable) 때문이다. virtual 함수가 있는 클래스는 내부적으로 vtable이라는 함수 포인터 배열을 가진다. 각 객체는 자신의 클래스에 맞는 vtable을 가리키는 포인터(vptr)를 숨겨진 멤버로 가지고 있다. 런타임에 p->speak()를 호출하면 vptr을 통해 vtable을 찾고, vtable에서 적절한 함수 주소를 가져와 호출한다.
Animal vtable Dog vtable
┌──────────────┐ ┌──────────────┐
│ Animal::speak │ │ Dog::speak │
└──────────────┘ └──────────────┘
Animal 객체 Dog 객체
┌──────┐ ┌──────┐
│ vptr ──→ Animal vtable │ vptr ──→ Dog vtable
│ ... │ │ ... │
└──────┘ └──────┘
동적 결합은 vtable 탐색이라는 간접 비용이 있어서 정적 결합보다 약간 느리다. 하지만 이 비용은 매우 작고, 다형성이 주는 유연함에 비하면 무시할 수 있는 수준이다.
정적 결합과 동적 결합 비교
핵심 차이를 정리하면 이렇다.
정적 결합 은 컴파일 타임에 결정되고, virtual 없는 함수와 오버로딩에 적용된다. 포인터/레퍼런스의 선언된 타입 을 기준으로 함수를 선택한다.
동적 결합 은 런타임에 결정되고, virtual 함수와 오버라이딩에 적용된다. 포인터/레퍼런스가 가리키는 실제 객체의 타입 을 기준으로 함수를 선택한다.
다형성을 제대로 활용하려면 virtual 키워드가 필수다. virtual 없이 자식에서 같은 이름의 함수를 정의하면 재정의가 아니라 함수 숨김(hiding) 이 되어, 의도한 대로 동작하지 않는다.
가상 소멸자
동적 결합에서 중요한 실전 규칙이 하나 있다. 부모 클래스의 소멸자에는 반드시 virtual을 붙여야 한다.
class Animal {
public:
virtual ~Animal() { // 가상 소멸자
cout << "Animal 소멸" << endl;
}
};
class Dog : public Animal {
private:
int *data;
public:
Dog() {
data = new int[100];
}
~Dog() {
delete[] data;
cout << "Dog 소멸" << endl;
}
};
int main() {
Animal *p = new Dog();
delete p; // Dog 소멸자 → Animal 소멸자 순서로 호출
return 0;
}
소멸자에 virtual이 없으면 delete p할 때 Animal의 소멸자만 호출되고 Dog의 소멸자는 호출되지 않는다. Dog이 동적 할당한 data 메모리가 해제되지 않아 메모리 누수 가 발생한다. virtual을 붙이면 실제 객체의 소멸자(Dog)가 먼저 호출되고, 이어서 부모의 소멸자(Animal)가 자동으로 호출된다.
상속을 사용할 가능성이 있는 클래스라면, 소멸자에 virtual을 붙이는 것을 습관으로 만들어야 한다.
4. 추상 클래스
4-1. 추상 클래스란?
추상 클래스는 순수 가상 함수(Pure Virtual Function)를 하나 이상 가진 클래스 다. 직접 객체를 만들 수 없고, 반드시 자식 클래스에서 상속받아 사용해야 한다.
순수 가상 함수는 본체(구현)가 없는 가상 함수 다. 선언부 끝에 = 0을 붙여서 표시한다.
class Shape {
public:
virtual void draw() = 0; // 순수 가상 함수
virtual double area() = 0; // 순수 가상 함수
virtual ~Shape() {}
};
draw()와 area()에 = 0이 붙어있다. "이 함수는 여기서 구현하지 않겠다. 자식 클래스가 반드시 구현해라"는 뜻이다.
// Shape s; // 에러! 추상 클래스는 객체 생성 불가
추상 클래스는 직접 인스턴스화할 수 없다. "도형"이라는 것은 추상적인 개념이지, 실제로 존재하는 구체적인 형태가 아니기 때문이다. 실제 존재하는 것은 원, 사각형, 삼각형 같은 구체적인 도형이다.
4-2. 추상 클래스의 사용
자식 클래스에서 순수 가상 함수를 전부 구현하면 그 자식 클래스는 객체를 만들 수 있다.
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
void draw() override {
cout << "원을 그린다 (반지름: " << radius << ")" << endl;
}
double area() override {
return 3.14159 * radius * radius;
}
};
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
void draw() override {
cout << "사각형을 그린다 (" << width << " x " << height << ")" << endl;
}
double area() override {
return width * height;
}
};
만약 자식 클래스에서 순수 가상 함수를 하나라도 구현하지 않으면, 그 자식 클래스도 추상 클래스가 되어 객체를 만들 수 없다.
4-3. 추상 클래스의 힘
추상 클래스의 진짜 가치는 부모 타입 포인터로 여러 자식 객체를 통일적으로 다룰 수 있다 는 것이다.
int main() {
Shape *shapes[3];
shapes[0] = new Circle(5.0);
shapes[1] = new Rectangle(4.0, 6.0);
shapes[2] = new Circle(3.0);
double totalArea = 0;
for (int i = 0; i < 3; i++) {
shapes[i]->draw();
totalArea += shapes[i]->area();
}
cout << "전체 면적: " << totalArea << endl;
for (int i = 0; i < 3; i++) {
delete shapes[i];
}
return 0;
}
Shape * 배열 하나로 Circle이든 Rectangle이든 상관없이 draw()와 area()를 호출할 수 있다. 새로운 도형(Triangle, Ellipse 등)을 추가하더라도 Shape을 상속받고 순수 가상 함수를 구현하기만 하면 된다. 기존 코드를 수정할 필요가 없다.
이것이 바로 추상 클래스가 인터페이스 역할 을 하는 것이다. "이 클래스를 상속받으면 반드시 이 함수들을 구현해야 한다"는 계약을 강제한다. JavaScript나 Java의 인터페이스와 비슷한 역할이다. 다만 C++에는 interface 키워드가 따로 없고, 순수 가상 함수만으로 이루어진 추상 클래스가 그 역할을 한다.
4-4. 일반 가상 함수와 순수 가상 함수의 차이
일반 가상 함수(virtual void draw() { ... }) 는 기본 구현이 있다. 자식이 재정의하지 않으면 부모의 구현이 사용된다. 부모 클래스의 객체를 만들 수 있다.
순수 가상 함수(virtual void draw() = 0) 는 기본 구현이 없다. 자식이 반드시 구현해야 한다. 부모 클래스는 추상 클래스가 되어 객체를 만들 수 없다.
기본 동작이 의미 있는 경우에는 일반 가상 함수를 쓰고, 자식마다 반드시 다르게 동작해야 하는 경우에는 순수 가상 함수를 쓴다.
마무리
부모·자식 사이의 타입 변환(업캐스팅/다운캐스팅)이 다형성의 토대가 되고, virtual 키워드가 동적 결합을 가능하게 하여 런타임 다형성을 실현한다. 추상 클래스는 이 다형성을 설계 수준에서 강제하는 도구다. "순수 가상 함수를 반드시 구현하라"는 계약을 통해, 서로 다른 자식 클래스들을 하나의 인터페이스로 통일적으로 다룰 수 있게 해준다. 이 개념들은 C++뿐 아니라 대부분의 객체지향 언어에서 핵심적으로 사용되므로, 확실히 이해해두면 다른 언어로 넘어갈 때도 큰 도움이 된다.