C#LAB
C# 2.4 · 2부. 객체지향

상속과 다형성

이미 있는 것을 물려받아 확장하고, 같은 이름으로 다르게 움직이게 합니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 기초
개념 설명

같은 것을 두 번 적지 않기

개와 고양이를 다루는 클래스를 각각 만든다고 해보겠습니다. 이름과 나이는 둘 다 가지고, 먹는 일도 둘 다 합니다. 다른 것은 우는 소리뿐인데, 클래스를 따로 만들면 같은 코드를 두 벌 적게 됩니다. 고칠 일이 생기면 두 곳을 함께 손봐야 하고, 한쪽만 고치면 조용히 어긋납니다.

상속은 공통된 것을 부모 클래스에 두고, 자식은 다른 것만 적게 합니다. 자식은 부모가 가진 것을 그대로 물려받습니다.

  • 부모(기반) 클래스 — 공통된 것을 담습니다.
  • 자식(파생) 클래스 — 물려받고, 다른 것만 더하거나 바꿉니다.
  • 다형성 — 부모 타입으로 객체를 다루더라도, 실제 인스턴스의 동작이 그대로 나타나는 성질입니다.

C#에서 부모는 하나만 둘 수 있습니다. 여럿에게서 물려받아야 할 때는 다음 단원의 인터페이스를 사용합니다.

최소 예제 · 편집 가능

부모 타입으로 객체를 담아 탐색하기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
Animal[] animals = { new Dog(), new Cat(), new Animal() };

foreach (Animal animal in animals) {
    animal.Speak();
}

class Animal {
    // virtual 이 "자식이 바꿔도 된다" 는 표시입니다
    public virtual void Speak() => Console.WriteLine("...");
}

class Dog : Animal {
    public override void Speak() => Console.WriteLine("멍멍");
}

class Cat : Animal {
    public override void Speak() => Console.WriteLine("야옹");
}
출력
멍멍 야옹 ...

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

이것이 다형성입니다. 배열의 타입은 Animal 이고 탐색하는 코드도 Animal 만 알지만, 실제로 불리는 것은 그 자리에 담긴 인스턴스의 동작입니다. 개가 짖고 고양이가 웁니다.

동물이 하나 더 늘어도 탐색하는 코드는 그대로입니다. 새 클래스를 더하고 배열에 넣기만 하면 됩니다. 조건문으로 종류를 하나하나 분기하는 코드였다면 그때마다 조건을 하나씩 더해야 했을 것입니다.

상세 사용법

부모가 열어야 자식이 바꿉니다

자식이 부모의 동작을 바꾸려면 부모 쪽에 virtual 이 붙어 있어야 합니다. 이것이 없으면 자식이 같은 이름을 적더라도 바꾸는 것이 아니라 가리는 것이 되어, 부모 타입으로 담아 부를 때 부모의 구현이 불립니다.

부모가 열지 않으면
class Animal {
    public void Speak() { }
}

class Dog : Animal {
    public new void Speak() { }
}

Animal a = new Dog();
a.Speak();   // Animal 의 구현이 불립니다
열어 두면
class Animal {
    public virtual void Speak() { }
}

class Dog : Animal {
    public override void Speak() { }
}

Animal a = new Dog();
a.Speak();   // Dog 의 구현이 불립니다

왼쪽처럼 new 로 가리는 것은 거의 언제나 실수입니다. 담은 변수의 타입이 무엇이냐에 따라 결과가 달라져, 읽는 사람이 짐작하기 어렵습니다. 바꾸고 싶다면 부모에 virtual 을 붙이고 자식에 override 를 적으세요.

부모의 구현을 그대로 사용하면서 덧붙이기

바꾼다고 해서 부모의 구현을 버려야 하는 것은 아닙니다. base 로 부모의 구현을 부른 뒤 덧붙일 수 있습니다.

class Animal {
    protected string Name { get; }

    public Animal(string name) { Name = name; }

    public virtual string Describe() => $"{Name}";
}

class Dog : Animal {
    // 부모 생성자에 넘깁니다 — 부모가 먼저 만들어집니다
    public Dog(string name) : base(name) { }

    public override string Describe() => base.Describe() + " (개)";
}

자식을 만들면 부모가 먼저 만들어집니다. 부모에 매개 변수 없는 생성자가 없다면 : base(...) 로 무엇을 넘길지 반드시 적어야 합니다.

여기서 protected 가 처음 제 몫을 합니다. 2.2에서 미뤄 두었던 것으로, 밖에서는 감추고 자식에게만 보이는 자리입니다. private 이면 자식도 볼 수 없습니다.

자식이 더 좁은 타입을 돌려주기

재정의할 때 돌려주는 타입은 원래 부모와 똑같아야 했습니다. 그래서 자기 자신을 복제해 돌려주는 메서드 같은 것을 만들면, 개를 복제했는데도 받는 쪽에서는 Animal 로 받아 다시 Dog 인지 밝혀야 했습니다.

C# 9.0부터는 자식이 더 좁은 타입을 돌려줄 수 있습니다. Animal 자리에 Dog 를 적어도 되는데, 개는 동물이기도 하므로 약속이 깨지지 않기 때문입니다.

class Animal {
    public virtual Animal Clone() => new Animal();
}

class Dog : Animal {
    // Animal 이 아니라 Dog 로 적었습니다(C# 9.0+)
    public override Dog Clone() => new Dog();
}

Dog copy = new Dog().Clone();   // 되돌려 밝히지 않아도 됩니다

실제로 무엇인지 물어보기

부모 타입으로 담아 두었지만 실제로 무엇인지 알아야 할 때가 있습니다. is 로 묻고 그 자리에서 받을 수 있습니다(C# 7.0+).

foreach (Animal animal in animals) {
    if (animal is Dog dog) {
        dog.Fetch();          // 개에게만 있는 일
    }
}

다만 이런 검사가 늘어난다면 설계를 다시 볼 때입니다. 종류마다 분기하는 코드가 곳곳에 생기면 상속으로 얻으려던 이득이 사라집니다. 그럴 때는 부모에 virtual 메서드를 두고 자식이 저마다 채우는 쪽이 낫습니다.

더 물려받지 못하게 막으려면 sealed 를 붙입니다. 물려받을 것을 염두에 두지 않고 만든 클래스라면 막아 두는 편이 안전합니다.

sealed class Poodle : Dog { }   // 여기서 끝입니다
버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
3.0
6.0
7.0
8.0
9.0
12.0
14.0
  • 1.0상속 · virtual · override · base · sealed
  • 7.0is 패턴 — 물으면서 그 자리에서 받기
  • 8.0switch 식으로 타입을 분기하기
  • 9.0covariant 반환 — 자식이 더 좁은 타입을 돌려주기
C# 1.0+

상속과 다형성은 C# 1.0부터 있었고 뼈대가 바뀌지 않았습니다. 뒤에 더해진 것은 실제 타입을 알아내는 방법이 주로입니다 — is 로 물으며 그 자리에서 받는 것(C# 7.0), switch 식으로 여러 타입을 한 번에 분기하는 것(C# 8.0)이 그렇습니다. 다만 그런 검사에 기대기보다 virtual 로 푸는 쪽이 대개 낫습니다.

실습 문제

직접 해보기

1. 도형의 넓이 난이도 하

Shape 를 부모로 두고 넓이를 돌려주는 메서드를 virtual 로 열어 둔 뒤, RectangleCircle 이 저마다 채우게 해보세요. 그리고 둘을 한 배열에 담아 넓이를 출력해보세요.

부모에 virtual, 자식에 override 입니다. 배열의 타입은 부모(Shape)로 두어야 둘을 함께 담을 수 있습니다.
Shape[] shapes = { new Rectangle(3, 4), new Circle(5) }; foreach (Shape shape in shapes) { Console.WriteLine(shape.Area()); } class Shape { public virtual double Area() => 0; } class Rectangle(double w, double h) : Shape { public override double Area() => w * h; } class Circle(double r) : Shape { public override double Area() => 3.14 * r * r; }
2. 부모의 구현에 덧붙이기 난이도 중

Employee 가 이름과 기본급을 가지고 급여를 돌려주게 하고, Manager 는 거기에 수당을 더해 돌려주게 해보세요. 급여를 셈하는 코드를 두 번 적지 않아야 합니다.

자식에서 base.메서드() 를 부르면 부모의 구현을 그대로 사용하면서 덧붙일 수 있습니다. 부모 생성자에는 : base(...) 로 넘깁니다.
Employee[] people = { new Employee("홍길동", 3000), new Manager("김철수", 3000, 1000) }; foreach (Employee person in people) { Console.WriteLine(person.Pay()); } class Employee { protected int Base { get; } public string Name { get; } public Employee(string name, int basePay) { Name = name; Base = basePay; } public virtual int Pay() => Base; } class Manager : Employee { private readonly int _bonus; public Manager(string name, int basePay, int bonus) : base(name, basePay) { _bonus = bonus; } public override int Pay() => base.Pay() + _bonus; }
요약
  • 공통된 것은 부모에, 다른 것만 자식에 적습니다. 부모는 하나만 둘 수 있습니다.
  • 부모가 virtual열어야 자식이 override 로 바꿉니다.
  • 다형성 — 부모 타입으로 다루어도 실제 인스턴스의 동작이 나타납니다. 종류가 늘어도 사용하는 코드는 그대로입니다.
  • protected밖에서는 감추고 자식에게만 보입니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.