오버라이드와 다형성
20261014 오버라이드와 다형성
오버라이드와 다형성
상속으로 물려받은 메서드를 자식이 그대로 쓰지 않고 자기 방식으로 다시 정의하는 것을 오버라이드라고 함. 그리고 부모 타입 변수 하나로 여러 자식을 다루면서 각자의 동작이 나오게 하는 것을 다형성이라고 함.
변수에 적힌 타입이 아니라 그 안에 실제로 들어있는 객체의 메서드가 실행됨. 이 한 가지 규칙이 다형성의 전부임.
모든 클래스의 부모 object
아무것도 물려받지 않아도 부모가 있음
C#의 모든 클래스는 아무것도 적지 않아도 object를 물려받음.
그래서 새로 만든 클래스도 처음부터 메서드 몇 개를 가지고 있음.
object의 메서드 | 하는 일 | 다시 정의 |
|---|---|---|
ToString() | 객체를 문자열로 바꿈 | 가능 |
Equals(object) | 다른 객체와 같은지 비교 | 가능 |
GetHashCode() | 값을 대표하는 정수를 냄 | 가능 |
GetType() | 실제 타입 정보를 냄 | 가능 |
GetType()으로 실제 타입 보기
GetType()은 변수에 적힌 타입이 아니라 실제로 만들어진 객체의 타입을 돌려줌.
예를 들어 int.GetType()이나 string.GetType()을 할 경우 int의 진짜 이름인 System.Int32, String은 System.String가 반환됨. 지금까지 써 온 짧은 이름은 닷넷 타입에 붙인 별칭임.
업캐스팅과 다운캐스팅
부모 타입 변수에 자식 담기
자식은 부모의 한 종류이므로, 부모 타입 변수에 자식 객체를 그대로 담을 수 있음. 이것을 업캐스팅이라고 하고 아무 표기 없이 됨.
// 업캐스팅 예시
Dog dog = new Dog();
dog.Name = "초코";
Animal animal = dog; // 업캐스팅
animal.Eat();
//RTTI(런타임 형 검사) 예시
Console.WriteLine($"변수의 타입으로는 Animal, 실제 객체는 {animal.GetType().Name}");
class Animal
{
public string Name;
public void Eat()
{
Console.WriteLine($"{Name}: 먹이를 먹음");
}
}
class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍");
}
}
객체는 여전히 Dog이지만, Animal타입 변수를 통해서는 Animal이 가진 멤버만 보임.
다시 자식 타입으로 꺼내기
부모 타입 변수를 자식 타입으로 되돌리는 것을 다운캐스팅이라고 함. 이쪽으 괄호로 타입을 적어야 함.
// 다운캐스팅 예시
Dog dog = new Dog();
dog.Name = "초코";
Animal animal = dog; // 업캐스팅
Dog again = (Dog)animal; // 다운캐스팅
again.Bark();
class Animal
{
public string Name;
}
class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍");
}
}
업캐스팅은 언제나 안전하지만 다운캐스팅은 그렇지 않음. 안에 들어있는 객체가 그 타입이 아니면 실행 중에 프로그램이 멈춤.
- 주의사항: 다운캐스팅 실패는 컴파일할 때 걸리지 않고 실행 중에 프로그램을 멈춤. 캐스팅 하기 전에 타입을 확인하는 습관이 필요함.
is로 타입 확인하기
is연산자는 왼쪽 객체가 오른쪽 타입인지 true/false로 알려줌
// is 사용 예시
Animal a1 = new Dog();
Animal a2 = new Cat();
Console.WriteLine($"a1 은 Dog 인가: {a1 is Dog}");
Console.WriteLine($"a1 은 Animal 인가: {a1 is Animal}");
Console.WriteLine($"a2 는 Dog 인가: {a2 is Dog}");
Console.WriteLine($"a2 는 Cat 인가: {a2 is Cat}");
class Animal { }
class Dog : Animal { }
class Cat : Animal { }
Dog객체는 Dog이기도 하고 Animal이기도 함. 부모 타입으로 물어도 true가 나옴.
확인한 뒤 맞는 타입만 캐스팅하면 멈추지 않음.
is뒤에 변수 이름을 붙이면 확인과 캐스팅을 한 번에 할 수 있음. 확인에 성공했을 때만 그 변수에 값이 담김.
// is뒤에 변수 이름을 붙여서 사용하는 예시
Animal a1 = new Dog();
a1.Name = "초코";
Animal a2 = new Cat();
a2.Name = "나비";
Describe(a1);
Describe(a2);
static void Describe(Animal animal)
{
if (animal is Dog dog)
{
dog.Bark();
}
else if (animal is Cat cat)
{
cat.Meow();
}
}
class Animal
{
public string Name;
}
class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍");
}
}
class Cat : Animal
{
public void Meow()
{
Console.WriteLine($"{Name}: 야옹");
}
}
as로 안전하게 캐스팅하기
as는 캐스팅에 실패하면 프로그램을 멈추는 대신 null을 돌려줌.
// as 사용 예시
Animal a1 = new Dog();
a1.Name = "초코";
Animal a2 = new Cat();
a2.Name = "나비";
TryBark(a1);
TryBark(a2);
static void TryBark(Animal animal)
{
Dog dog = animal as Dog;
if (dog != null)
{
dog.Bark();
}
else
{
Console.WriteLine($"{animal.Name}: 짖을 수 없음");
}
}
class Animal
{
public string Name;
}
class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}: 멍멍");
}
}
class Cat : Animal
{
}
| 방법 | 실패했을 때 |
|---|---|
(Dog)animal | 프로그램이 멈춤 |
animal is Dog | false |
animal is Dog dog | false, 변수는 채워지지 않음 |
animal is Dog | null |
메서드 오버라이드
자식마다 다르게 동작시키기
업캐스팅을 해 두면 부모가 가진 멤버만 쓸 수 있음. 그런데 그 멤버가 자식마다 다르게 동작한다면 부모타입 하나로 모두를 다룰 수 있게 됨.
그러려면 부모 쪽에virtual,자식 쪽에 override를 붙임.
// 오버라이드 사용 예시
Animal animal = new Animal();
animal.Speak();
Animal dog = new Dog();
dog.Speak();
Animal cat = new Cat();
cat.Speak();
class Animal
{
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인데 출력이 셋 다 다름. 이 가상 메서드 호출은 객체의 실제 타입에 맞는 재정의를 선택하기 때문임. 숨긴 메서드나 일반 베서드 호출까지 같은 규칙으로 고르는 것은 아님.
| 키워드 | 붙이는 곳 | 뜻 |
|---|---|---|
virtual | 부모 메서드 | 자식이 다시 정의해도 됨 |
override | 자식 메서드 | 부모의 그 메서드를 다시 정의함 |
virtual이 없으면
부모에 virtual이 없는데 자식이 override하려 하면 컴파일 되지 않음.
//virtual 선언하지 않고 override시 컴파일 에러발생.
Dog dog = new Dog();
dog.Speak();
class Animal
{
public void Speak()
{
Console.WriteLine("동물이 소리를 냄");
}
}
class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("멍멍");
}
}
위 예시에서 부모의 가상 메서드와 같은 이름, 매개변수, 반환타입으로 override함. 이름이나 매개변수를 바꾸면 그 메서드를 재정의하는 것이 아님.
base.로 부모 것을 함께 쓰기
다시 정의하되 부모가 하던 일을 버리고 싶지 않을 때가 있음. base.메서드이름()으로 부모의 원래 메서드를 부를 수 있음.
// base. 사용 예시
Manager manager = new Manager();
manager.Name = "홍길동";
manager.Introduce();
class Employee
{
public string Name;
public virtual void Introduce()
{
Console.WriteLine($"이름: {Name}");
}
}
class Manager : Employee
{
public override void Introduce()
{
base.Introduce(); // 부모가 하던 출력
Console.WriteLine("직책: 관리자"); // 자식이 더하는 출력
}
}
부르는 위치는 자유롭게 정할 수 있음. 부모의 것을 먼저 부를 수도, 나중에 부를 수도 있음.
여러 단계에 걸친 오버라이드
override한 메서드는 그 자식이 다시 override할 수 있음. virtual을 다시 붙이지 않아도 됨.
// 이미 override한 메서드를 자식 클래서에서 다시 override하는 예시
Level1 a = new Level1();
Level2 b = new Level2();
Level3 c = new Level3();
a.Show();
b.Show();
c.Show();
class Level1
{
public virtual void Show()
{
Console.WriteLine("1단계");
}
}
class Level2 : Level1
{
public override void Show()
{
Console.WriteLine("2단계");
}
}
class Level3 : Level2
{
public override void Show()
{
base.Show();
Console.WriteLine("3단계");
}
}
다형성
부모 타입 배열로 묶기
오버라이드와 업캐스팅을 합치면, 서로 다른 자식들을 한 배열에 담아 같은 코드로 다룰 수 있음.
// 여러 자식들을 부모 클래스배열로 묶어 foreach로 한번에 각자 override된 메서드를 실행시키는 예시
Character[] party =
{
new Warrior("전사"),
new Mage("마법사"),
new Archer("궁수")
};
foreach (Character member in party)
{
member.Attack();
}
class Character
{
protected string name;
public Character(string name)
{
this.name = name;
}
public virtual void Attack()
{
Console.WriteLine($"{name}: 맨손으로 때림");
}
}
class Warrior : Character
{
public Warrior(string name) : base(name) { }
public override void Attack()
{
Console.WriteLine($"{name}: 검으로 벰");
}
}
class Mage : Character
{
public Mage(string name) : base(name) { }
public override void Attack()
{
Console.WriteLine($"{name}: 불덩이를 던짐");
}
}
class Archer : Character
{
public Archer(string name) : base(name) { }
public override void Attack()
{
Console.WriteLine($"{name}: 화살을 쏨");
}
}
// 출력 결과
전사: 검으로 벰
마법사: 불덩이를 던짐
궁수: 화살을 쏨
foreach안에는 if가 하나도 없음. 타입을 검사해 분기하지 않고, 어떤 자식이 오든 그 자식의 Attack()이 실행됨. 각자가 Attack()에 대한 override가 되어있기 때문에 서로 다른 결과가 나타난다.
새 자식이 늘어나도 쓰는 쪽은 그대로
다형성의 장점은 자식이 늘어날 때 드러남. 타입마다 분기하면 종류가 늘 때마다 분기를 하나씩 더 붙여야 함.
// 여러 자식을 부모클래스로 묶어서 새로운 자식이 추가되어도 foreach 내부에 대한 수정 없이 간편하게 출력하는 예시
Shape[] shapes =
{
new Rectangle(10, 5),
new Circle(3),
new Triangle(8, 6)
};
double total = 0;
foreach (Shape shape in shapes)
{
Console.WriteLine($"{shape.GetType().Name} 넓이: {shape.GetArea():F2}");
total += shape.GetArea();
}
Console.WriteLine($"합계: {total:F2}");
class Shape
{
public virtual double GetArea()
{
return 0;
}
}
class Rectangle : Shape
{
private double width;
private double height;
public Rectangle(double width, double height)
{
this.width = width;
this.height = height;
}
public override double GetArea()
{
return width * height;
}
}
class Circle : Shape
{
private double radius;
public Circle(double radius)
{
this.radius = radius;
}
public override double GetArea()
{
return Math.PI * radius * radius;
}
}
class Triangle : Shape
{
private double baseLength;
private double height;
public Triangle(double baseLength, double height)
{
this.baseLength = baseLength;
this.height = height;
}
public override double GetArea()
{
return baseLength * height / 2;
}
}
도형이 하나 더 늘어도 foreach 부분은 손대지 않음. 새 클래스가 Shape를 물려받고 GetArea()를 override하기만 하면 됨.
new로 숨기기
다시 정의가 아니라 가리기(멤버 하이딩)
virtual, override없이 자식에 같은 이름의 메서드를 두면, 부모의 것을 가리는 것이 됨. 의도한 것임을 밝히려면 new를 붙임.
// new와 override의 차이점 예시
Child child = new Child();
Console.WriteLine("자식 타입 변수로 호출");
child.Hide();
child.Replace();
Console.WriteLine();
Console.WriteLine("부모 타입 변수로 호출");
Parent parent = child;
parent.Hide();
parent.Replace();
class Parent
{
public void Hide()
{
Console.WriteLine(" 부모의 Hide");
}
public virtual void Replace()
{
Console.WriteLine(" 부모의 Replace");
}
}
class Child : Parent
{
public new void Hide()
{
Console.WriteLine(" 자식의 Hide");
}
public override void Replace()
{
Console.WriteLine(" 자식의 Replace");
}
}
출력 결과
자식 타입 변수로 호출
자식의 Hide
자식의 Replace
부모 타입 변수로 호출
부모의 Hide
자식의 Replace
Hide()는 변수에 적힌 타입을 따라가고, Replace()는 실제 객체를 따라감. 부모 타입 변수에 담으면 new로 숨긴 메서드에는 다형성이 적용되지 않음.
| 구분 | new(숨기기) | override(재정의) |
|---|---|---|
| 부모 쪽 표기 | 필요 없음 | 재정의 가능한 virtual, abstract, override |
| 자식 쪽 표기 | new | override |
| 자식 타입으로 호출 | 자식 것 | 자식 것 |
| 부모 타입으로 호출 | 부모 것 | 자식 것 |
| 다형성 | 안 됨 | 됨 |
new를 빠뜨리면 컴파일은 되지만 경고가 남.
오버로드와 오버라이드
이름이 비슷해 헷갈리지만 서로 다른 문법임.
| 구분 | 오버로드 | 오버라이드 |
|---|---|---|
| 무엇이 여럿인가 | 같은 이름, 다른 매개변수 | 같은 이름, 같은 매개변수 |
| 어디에 있는가 | 같은 클래스 또는 상속 관계의 메서드들 | 부모와 자식에 나뉘어 있음 |
| 고르는 시점 | 컴파일할 때 인수를 보고 | 실행할 때 객체를 보고 |
| 표기 | 없음 | virtual, override |
추상 클래스
기본 동작이 마땅치 않을 때
앞의 도형 예제(새 자식이 늘어나도 쓰는 쪽은 그대로에 있는)에서 Shape.GetArea()는 0을 돌려줬음. 도형이 아닌 것의 넓이를 물어볼 일이 없는데도 부모가 억지로 답을 내놓은 것임. 게다가 Shape 객체 자체를 만들 수도 있었음.
abstract를 쓰면 본문 없이 이름만 정하고 자식에게 구현을 강제할 수 있음.
// 부모 클래스로만 사용하기 위한 abstract. 객체 생성이 안된다.
// abstract를 사용하여 추상 함수로 자식 클래스에서 구현해야 할 메서드를 강제하는 예시
Shape[] shapes =
{
new Rectangle(10, 5),
new Circle(3)
};
foreach (Shape shape in shapes)
{
Console.WriteLine($"{shape.GetName()} 넓이: {shape.GetArea():F2}");
}
abstract class Shape
{
// 추상 메서드: 본문이 없음
public abstract double GetArea();
// 일반 메서드: 본문이 있음
public string GetName()
{
return GetType().Name;
}
}
class Rectangle : Shape
{
private double width;
private double height;
public Rectangle(double width, double height)
{
this.width = width;
this.height = height;
}
public override double GetArea()
{
return width * height;
}
}
class Circle : Shape
{
private double radius;
public Circle(double radius)
{
this.radius = radius;
}
public override double GetArea()
{
return Math.PI * radius * radius;
}
}
추상클래스에는 본문 있는 메서드와 없는 메서드를 함께 둘 수 있음. 필드와 생성자도 그대로 둘 수 있음.
추상 클래스는 new할 수 없음
Shape shape = new Shape();
abstract class Shape
{
public abstract double GetArea();
}
부모 타입 변수로 쓰는 것은 여전히 가능함. 담기는 객체가 자식이면 됨.
구현하지 않으면 오류
Square square = new Square();
abstract class Shape
{
public abstract double GetArea();
}
class Square : Shape
{
}
위와 같이 추상 함수를 선언해놓고 자식 클래스에서 해당 함수를 구현하지 않으면 오류(컴파일 에러)가 발생한다.
virtual과 abstract
| 구분 | virtual | abstract |
|---|---|---|
| 부모의 본문 | 있음 | 없음 |
| 자식의 재정의 | 선택 | 필수 |
| 클래스 선언 | 보통 클래스 | abstract class여야 함 |
| 언제 쓰는가 | 쓸 만한 기본 동작이 있음 | 자식마다 반드시 달라야 함 |
- 참고: 구현을 하나도 담지 않고 "이런 메서드를 갖추라"는 약속만 모으는 문법이 따로 있음. 추상 클래스와 어떻게 다른지는 여기서 다루지 않음.
sealed override
override한 메서드에 sealed를 붙이면 그 아래 자식이 더 이상 다시 정의하지 못함.
using System;
Level1 a = new Level1();
Level2 b = new Level2();
Level3 c = new Level3();
a.Show();
b.Show();
c.Show();
class Level1
{
public virtual void Show()
{
Console.WriteLine("1단계 규칙");
}
}
class Level2 : Level1
{
public sealed override void Show()
{
Console.WriteLine("2단계 규칙 — 여기서 고정");
}
}
class Level3 : Level2
{
public void Extra()
{
Console.WriteLine("다른 이름의 메서드는 얼마든지 가능");
}
}
봉인한 메서드를 다시 정의하려 하면 컴파일되지 않음.
클래스에 붙이는 sealed는 상속 자체를 막고, sealed override는 메서드 하나의 재정의만 막음.
ToString()재정의
객체를 그대로 출력하면
Console.WriteLine()에 객체를 넘기면 그 객체의 ToString()이 불림. 재정의하지 않았으면 object의 기본 동작대로 타입 이름이 나옴.
// ToString() 재정의 없이 객체를 출력했을 때의 예시
Person person = new Person("홍길동", 30);
Console.WriteLine(person);
class Person
{
private string name;
private int age;
public Person(string name, int age)
{
this.name = name;
this.age = age;
}
}
위 예시대로 출력할 경우 출력내용은 클래스의 데이터 형인 Person만 출력된다.
ToString()은 object에 virtual로 선언되어 있으므로 override할 수 있음.
// ToString() 재정의 후 출력하는 예시
Person person = new Person("홍길동", 30);
Console.WriteLine(person);
Console.WriteLine($"보간 문자열 안에서도: {person}");
Console.WriteLine("문자열 연결에서도: " + person);
class Person
{
private string name;
private int age;
public Person(string name, int age)
{
this.name = name;
this.age = age;
}
public override string ToString()
{
return $"{name}({age}세)";
}
}
위와 같이 한번 재정의 해 두면 출력, 보간, 연결 어디서든 그 모양이 쓰임.
상속 계층에서의 ToString()
자식이 부모의 ToString()을 base.ToString()으로 불러 이어 쓸 수 있음.
using System;
Item[] items =
{
new Item("나무 막대", 100),
new Weapon("강철 검", 12000, 25),
new Weapon("녹슨 단검", 300, 4)
};
foreach (Item item in items)
{
Console.WriteLine(item);
}
class Item
{
protected string name;
protected int price;
public Item(string name, int price)
{
this.name = name;
this.price = price;
}
public override string ToString()
{
return $"{name} ({price:N0}원)";
}
}
class Weapon : Item
{
private int damage;
public Weapon(string name, int price, int damage) : base(name, price)
{
this.damage = damage;
}
public override string ToString()
{
return $"{base.ToString()} 공격력 {damage}";
}
}
배열 하나를 foreach로 돌리며 Console.WriteLine(item)만 했는데 항목마다 다른 모양으로 나옴. 이것도 다형성임.
Equals와 GetHashCode
object의 Equals()는 기본적으로 같은 객체인지를 봄. 필드 값이 모두 같아도 따로 만든 객체면 다르다고 답함.
내용이 같으면 같다고 보게 하려면 Equals()를 재정의 함. 이때 GetHashCode()도 함께 재정의 해야 함. - 값이 같은 두 객체가 서로 다른 해시 코드를 내면 나중에 값으로 찾아 쓰는 자리에서 어긋나기 때문임.
// Equals()와 GetHashCode()를 재정의 하는 예시
Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
Point p3 = new Point(5, 6);
Console.WriteLine($"p1.Equals(p2): {p1.Equals(p2)}");
Console.WriteLine($"p1.Equals(p3): {p1.Equals(p3)}");
Console.WriteLine($"해시가 같은가: {p1.GetHashCode() == p2.GetHashCode()}");
Console.WriteLine($"p1 == p2: {p1 == p2}");
class Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object obj)
{
Point other = obj as Point;
if (other == null)
{
return false;
}
return X == other.X && Y == other.Y;
}
public override int GetHashCode()
{
return X * 397 + Y;
}
public override string ToString()
{
return $"({X}, {Y})";
}
}
출력 결과
p1.Equals(p2): True
p1.Equals(p3): False
해시가 같은가: True
p1 == p2: False
출력결과의 마지막 줄이 그대로 False인 것에 주의할 것. Equals()를 재정의 해도 ==는 따라 바뀌지 않음. ==가 하는 일을 바꾸는 방법(연산자 재정의)은 여기서 다루지 않음.
- 주의사항:
Equals()를 재정의 하면서GetHashCode()를 두지 않으면 경고CS0659가 남.Equals()를 통하여 같다는 판정이 난다면GetHashCode()로 값을 비교했을 때에도 값이 같아야 하기때문임.



