같은 호출인데 객체마다 다르게 동작한다
앞 글에서 전사와 마법사를 한 목록에 담았습니다. 이제 각자 공격하게 해 봅시다.
for (int i = 0; i < party.Count; i++)
{
if (party[i] is Warrior w) w.Slash();
else if (party[i] is Mage m) m.Fireball();
}
돌아갑니다. 그런데 궁수를 추가하면 else if 를 하나 더 적어야 합니다.
그리고 이런 갈라지기가 코드 여기저기에 흩어집니다.
공격할 때 한 벌, 이동할 때 한 벌, 저장할 때 한 벌입니다.
직업이 하나 늘 때마다 고칠 자리가 여러 곳으로 흩어집니다.
부르는 이름을 하나로 하고, 무엇이 실행될지는 객체가 정하게 합니다.
virtual 과 override
부모에 virtual 을 붙이면 “자식이 갈아 끼울 수 있다” 는 표시가 됩니다.
자식은 override 로 갈아 끼웁니다.
class Character
{
public virtual void Attack()
{
Console.WriteLine("공격");
}
}
class Warrior : Character
{
public override void Attack()
{
Console.WriteLine("칼로 벤다");
}
}
class Mage : Character
{
public override void Attack()
{
Console.WriteLine("불덩이를 던진다");
}
}
이제 앞의 반복문이 이렇게 됩니다.
for (int i = 0; i < party.Count; i++)
{
party[i].Attack();
}
party[i] 는 Character 타입입니다. 그런데 실행되는 것은 그 자리에 든 객체의 것입니다.
이걸 다형성이라고 합니다. 같은 호출이 객체에 따라 다르게 동작한다는 뜻입니다.
앞에서 오버로딩은 컴파일할 때 정해진다고 했습니다. 오버라이딩은 실행 중에 정해집니다. 그 자리에 실제로 무엇이 들었는지를 보고 고릅니다.
진짜 값어치는 부르는 쪽에 있다
궁수를 추가해 봅니다.
class Archer : Character
{
public override void Attack()
{
Console.WriteLine("화살을 쏜다");
}
}
부르는 쪽 코드는 한 글자도 안 고칩니다. party[i].Attack() 그대로입니다.
이게 다형성을 쓰는 이유입니다. 갈라지기를 없앤 것이 아니라 한 곳으로 모은 것입니다. 어느 공격을 할지는 각 클래스 안에 한 번씩 적혀 있고, 부르는 쪽은 그 판단을 아예 안 합니다.
새 종류를 추가할 때 고칠 자리가 아니라 더할 자리만 생깁니다.
virtual 을 안 붙이면
virtual 없이 자식이 같은 이름의 메서드를 만들면 다르게 동작합니다.
class Character
{
public void Attack() { Console.WriteLine("공격"); }
}
class Warrior : Character
{
public new void Attack() { Console.WriteLine("칼로 벤다"); }
}
Character c = new Warrior();
c.Attack(); // 공격
Warrior 를 넣었는데 부모의 것이 실행됐습니다.
new 는 갈아 끼운 것이 아니라 가린 것입니다. 부모 것은 그대로 있고 이름만 겹칩니다.
그래서 보고 있는 타입이 무엇이냐로 결정됩니다.
Warrior w = new Warrior();
w.Attack(); // 칼로 벤다
Character c = w;
c.Attack(); // 공격, 같은 객체인데 다릅니다
같은 객체인데 어느 이름으로 부르냐에 따라 다르게 동작합니다. 거의 항상 버그입니다.
new 로 가리는 것은 실무에서 쓸 일이 드뭅니다.
자식이 갈아 끼울 수 있게 하려면 부모에 virtual 을 붙여야 합니다.
C# 이 이렇게 만든 것은 일부러입니다.
아무 메서드나 갈아 끼울 수 있으면, 부모를 만든 사람이 예상 못 한 자리가 바뀝니다.
virtual 은 “여기는 바뀌어도 된다” 고 미리 허락한 자리입니다.
base 로 부모 것을 부를 수 있다
갈아 끼우면서 부모 것도 하고 싶을 때가 있습니다.
class Warrior : Character
{
public override void Attack()
{
base.Attack(); // 부모의 Attack 을 먼저
Console.WriteLine("칼로 벤다");
}
}
base 는 부모를 가리킵니다. 생성자에서 : base(...) 를 쓴 것과 같은 base 입니다.
공통 준비는 부모에 두고 자식은 자기 것만 더하는 식으로 쓸 수 있습니다.
오버로딩과 오버라이딩은 다른 것이다
이름이 비슷해서 헷갈리는데 관계가 없습니다.
| 오버로딩 | 오버라이딩 | |
|---|---|---|
| 어디서 | 한 클래스 안에서 | 부모와 자식 사이에서 |
| 무엇이 | 이름은 같고 매개변수가 다름 | 이름도 매개변수도 같음 |
| 언제 정해지나 | 컴파일할 때 | 실행 중에 |
| 하는 일 | 이름 하나로 여러 재료를 받음 | 부모의 것을 자식이 갈아 끼움 |
void Print(int n) // 오버로딩
void Print(string s)
public override void Attack() // 오버라이딩
ToString 은 이미 오버라이딩이었다
모든 클래스는 object 를 물려받습니다. 안 적어도 그렇습니다.
object 에 ToString 이 virtual 로 들어 있습니다.
Console.WriteLine(w); // Warrior
객체를 그대로 출력하면 타입 이름이 나오는데, 그게 object 의 기본 ToString 입니다.
갈아 끼우면 원하는 대로 나옵니다.
class Warrior : Character
{
public override string ToString()
{
return "전사 " + name;
}
}
Console.WriteLine(w); // 전사 바둑
앞에서 해시 테이블을 볼 때 “직접 만든 타입은 왜 내용이 같아도 다른가” 를 남겨 뒀습니다.
답이 같은 자리에 있습니다. object 에는 Equals 와 GetHashCode 도 virtual 로 있고,
기본 구현이 내용이 아니라 만들어진 개체를 기준으로 판단합니다.
내용으로 같다고 보게 하려면 둘 다 갈아 끼워야 합니다.
class Point
{
public int X, Y;
public override bool Equals(object obj)
{
if (obj is Point p) return X == p.X && Y == p.Y;
return false;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
}
둘을 같이 갈아 끼워야 합니다. 하나만 하면 해시 테이블이 깨집니다. 같다고 볼 값이 다른 칸으로 가 버리니까요.
정리
- 부모에
virtual을 붙이면 자식이override로 갈아 끼울 수 있습니다. - 부모 타입으로 불러도 그 자리에 든 객체의 것이 실행됩니다. 실행 중에 정해집니다.
- 새 자식을 추가해도 부르는 쪽 코드는 안 고칩니다. 이게 다형성을 쓰는 이유입니다.
virtual없이 같은 이름을 만들면 갈아 끼운 것이 아니라 가린 것입니다. 거의 버그입니다.base로 부모의 것을 부를 수 있습니다.- 오버로딩은 컴파일할 때, 오버라이딩은 실행 중에 정해집니다. 이름만 비슷합니다.
- 모든 클래스는
object를 물려받습니다.ToString,Equals,GetHashCode가 거기 있습니다. - 내용으로 같다고 보려면
Equals와GetHashCode를 같이 갈아 끼웁니다.