(C#) 22. 클래스 형식 변환
자식을 부모 타입에 담는 건 항상 되는데 반대는 조건부다. 캐스팅·is·as 세 가지 방법의 실패 방식이 어떻게 다른지, C# 7의 패턴 매칭으로 어떻게 줄어드는지 정리했다.
부모 타입으로 받으면 다 받을 수 있다
20편에서 Knight, Mage, Archer가 전부 Player를 상속했다. 그러면 함수 하나로 셋을 다 받을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System;
namespace ClassTypeConv
{
// 클래스 형식 변환
// OOP (은닉성 / 상속성 / 다형성)
class Player
{
protected int hp;
protected int attack;
}
class Knight : Player
{
}
class Mage : Player
{
public int mp;
}
class Program
{
static void EnterGame(Player player)
{
//bool isMage = (player is Mage);
//if (isMage)
//{
// Mage mage = (Mage)player;
// mage.mp = 10;
//}
Mage mage = (player as Mage);
if (mage != null)
{
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
// Mage type -> Player type 가능
// Player type -> Mage type ? Case by case
EnterGame(knight);
}
}
}
주석에 적힌 두 줄이 이 편의 요지다.
한 방향은 항상 되고 반대는 아니다
1
Player p = new Knight(); // 항상 된다
Knight는 Player가 가진 것을 전부 갖고 있다. 그래서 Player로 다뤄도 부족한 게 없다. 캐스팅도 필요 없다. 이걸 업캐스팅이라고 한다.
1
2
Player p = new Knight();
Mage m = (Mage)p; // 실행 중 예외
반대는 다르다. Player 변수에 실제로 들어 있는 게 Mage인지 Knight인지 알 수 없다. Knight가 들어 있는데 Mage로 다루려 하면 mp 같은 게 없어서 곤란해진다.
여기서 헷갈렸던 게 하나 있다. 변수 타입이 바뀌어도 객체 자체는 안 바뀐다. Player p = new Knight();에서 힙에 있는 건 여전히 Knight다. p라는 창으로 볼 때 Player 부분만 보이는 것뿐이다.
그래서 다운캐스팅은 “바꾸는” 게 아니라 “원래 그거였는지 확인하는” 작업이다.
세 가지 방법과 실패 방식
캐스팅
1
Mage mage = (Mage)player;
아니면 InvalidCastException이 난다. 확실히 Mage일 때만 쓴다. 틀렸다는 걸 즉시 알 수 있는 게 장점이라, 절대 아닐 리 없는 자리에서는 오히려 이게 낫다.
is
1
bool isMage = (player is Mage);
맞는지만 알려준다. 예외도 안 나고 변환도 안 한다. 검사한 뒤에 다시 캐스팅해야 해서, 주석 처리된 코드처럼 타입 검사를 두 번 하게 된다.
as
1
2
Mage mage = (player as Mage);
if (mage != null) { }
실패하면 예외 대신 null을 준다. 검사가 한 번뿐이라 is + 캐스팅보다 낫다.
주의할 점은 as가 참조 타입과 nullable에만 된다는 것이다. int 같은 값 형식에는 못 쓴다. null을 돌려줄 방법이 없기 때문이다.
1
2
3
object o = 5;
int n = o as int; // 컴파일 에러
int? n2 = o as int?; // 이건 된다
C# 7 부터는 한 줄이다
as 다음에 null 검사를 하는 형태가 워낙 흔해서 문법이 생겼다.
1
2
3
4
if (player is Mage mage)
{
mage.mp = 10;
}
검사와 변수 선언이 한 번에 끝난다. mage는 if 블록 안에서 쓸 수 있고, 조건이 참일 때만 유효하니 null 검사도 필요 없다.
지금 새로 짠다면 이 형태를 쓴다. as + null 검사는 그 이전 방식이다.
여러 타입을 나눠 처리해야 하면 switch에도 쓸 수 있다.
1
2
3
4
5
6
switch (player)
{
case Mage m: m.mp = 10; break;
case Knight k: /* ... */ break;
default: break;
}
5편에서 본 switch가 값이 아니라 타입으로도 갈라진다.
이 코드는 아무 일도 안 한다
1
EnterGame(knight);
knight를 넘기니 player as Mage가 null이고, mp = 10은 실행되지 않는다. 예제가 의도한 대로 “안전하게 걸러진” 상태다.
EnterGame(mage)로 바꿔야 mp가 설정된다. 두 경우를 다 넣어보면 as가 무엇을 하는지 확실해진다.
다운캐스팅이 많아지면 설계를 의심한다
이 코드를 보면 EnterGame이 “법사면 마나를 채운다”를 하고 있다. 직업이 늘어나면 이런 게 계속 붙는다.
1
2
3
if (player is Mage m) { m.mp = 10; }
else if (player is Knight k) { k.shield = 5; }
else if (player is Archer a) { a.arrows = 30; }
새 직업을 추가할 때마다 이 함수를 고쳐야 한다. 고칠 곳이 여러 군데면 하나를 빠뜨린다.
각자 자기 방식으로 준비하게 하면 이 분기가 사라진다.
1
2
3
4
5
6
7
class Player { public virtual void OnEnterGame() { } }
class Mage : Player { public override void OnEnterGame() { mp = 10; } }
static void EnterGame(Player player)
{
player.OnEnterGame(); // 누구든 알아서 한다
}
이게 23편의 다형성이다. 다운캐스팅으로 타입을 확인하는 코드가 반복되면 다형성으로 풀 수 있는지 먼저 보게 됐다.
물론 다운캐스팅이 필요한 자리도 있다. 부모가 몰라도 되는 기능을 특정 자식만 갖는 경우가 그렇다. 부모에 OnEnterGame 같은 걸 계속 추가하면 부모가 자식들의 사정을 다 알게 되어 그것대로 나빠진다.
is 와 GetType 은 다르다
1
2
3
4
Player p = new Knight();
Console.WriteLine(p is Player); // True
Console.WriteLine(p.GetType() == typeof(Player)); // False
is는 “그 타입으로 취급할 수 있는가”를 묻는다. Knight는 Player이기도 하니 참이다.
GetType()은 실제 타입을 정확히 돌려준다. Knight이지 Player가 아니다.
상속을 포함해서 판단하려면 is, 정확히 그 타입인지 봐야 하면 GetType()이다. 대부분은 is가 맞았다.
정리하면
- 자식을 부모 타입에 담는 건 항상 되고 캐스팅도 필요 없다
- 부모를 자식으로 되돌리는 건 실제 객체가 그것일 때만 된다. 변수 타입이 바뀌어도 객체는 안 바뀐다
(Mage)는 실패 시 예외,is는 검사만,as는 실패 시nullas는 참조 타입과 nullable에만 쓸 수 있다- C# 7의
if (player is Mage mage)가 검사와 선언을 한 번에 처리한다 - 타입을 확인해 갈라지는 코드가 반복되면 다형성으로 풀 수 있는지 본다
is는 상속을 포함하고GetType()은 정확한 타입만 본다