Skip to main content

Command Palette

Search for a command to run...

C언어 (10)

Updated
10 min read

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;
}

DogAnimal을 상속받았으므로 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++뿐 아니라 대부분의 객체지향 언어에서 핵심적으로 사용되므로, 확실히 이해해두면 다른 언어로 넘어갈 때도 큰 도움이 된다.

More from this blog

C언어 (13)

1. STL의 개념 1-1. 배경 C++로 프로그래밍을 하다 보면 동적 배열, 연결 리스트, 정렬, 검색 같은 자료구조와 알고리즘을 반복적으로 구현하게 된다. 프로젝트마다 매번 새로 만들면 시간도 낭비되고, 버그가 생길 가능성도 높아진다. 이런 문제를 해결하기 위해 자주 사용되는 자료구조와 알고리즘을 미리 만들어서 표준 라이브러리에 포함 시킨 것이 STL이

Apr 1, 202610 min read3

chamdom's tech

16 posts