thenullpage.com
회원 클래스를 하나 만들어 두고 나면 곧 관리자 클래스가 필요해진다. 관리자도 이름이 있고 자기소개를 하는 건 똑같은데 담당 팀 같은 게 하나 더 붙는다. 회원 클래스를 통째로 복사해 만들면 같은 코드가 두 벌이 되고, 한쪽만 고쳐 놓고 다른 쪽을 잊는 사고가 난다. 자바는 이미 있는 클래스를 밑바탕 삼아 새 클래스를 만드는 상속을 준비해 뒀다.
extends는 남의 코드를 물려받는 선언이다. 물려주는 쪽을 부모 클래스, 물려받는 쪽을 자식 클래스라 부른다. 자식은 부모의 필드와 메서드를 자기 것처럼 쓴다.
public class Member {
protected String name;
public Member(String name) {
this.name = name;
}
public void introduce() {
System.out.println("회원 " + name);
}
}
public class Admin extends Member {
private String team;
public Admin(String name, String team) {
super(name);
this.team = team;
}
public String getTeam() {
return team;
}
}
Admin a = new Admin("김민수", "운영");
a.introduce(); // 회원 김민수
Admin 안에는 introduce가 없는데 부르니까 돌아간다. Member에서 물려받았기 때문이다. name을 protected로 둔 것도 이유가 있다. private이면 자식 클래스 안에서도 그 필드를 직접 못 건드린다. 자식에게만 열어 주고 싶을 때 protected를 쓴다.
super는 부모 쪽을 가리킨다. 생성자 안의 super(name)은 부모 생성자를 부르는 문장이다. 자식 객체가 만들어지려면 부모 몫이 먼저 채워져야 하므로 super 호출은 생성자의 첫 줄에만 올 수 있다.
public Admin(String name, String team) {
this.team = team;
super(name); // 컴파일 에러: call to super must be first statement
}
super를 아예 안 적으면 컴파일러가 인자 없는 super()를 몰래 끼워 넣는다. 그런데 Member에는 인자 없는 생성자가 없으므로 그 자리에서 에러가 난다.
오버라이딩은 물려받은 메서드를 덮어쓰는 것. 부모의 introduce가 마음에 안 들면 자식이 같은 이름, 같은 인자로 다시 정의하면 된다.
@Override
public void introduce() {
super.introduce();
System.out.println("담당: " + team + "팀");
}
new Admin("김민수", "운영").introduce();
// 출력:
// 회원 김민수
// 담당: 운영팀
super.introduce()는 덮어쓰기 전 부모 버전을 부르는 문법이다. 이게 없으면 부모 동작은 사라지고 자식 코드만 돈다. 여기서 자주 헷갈리는 게 오버로딩과 오버라이딩이다.
@Override
public void introduce(String prefix) { // 컴파일 에러: method does not override
System.out.println(prefix + name);
}
인자가 하나 붙는 순간 부모의 introduce와는 다른 메서드가 된다. 덮어쓴 게 아니라 이름만 같은 메서드를 하나 더 만든 것이라 원래 introduce는 그대로 살아 있다. 오버라이딩은 이름과 인자 목록이 완전히 같아야 성립하고, @Override를 붙여 두면 이런 착각이 컴파일 단계에서 잡힌다.
부모 타입 변수에 자식 객체를 담을 수 있다. Admin은 Member이기도 하므로 아래 코드가 성립한다. 자식을 부모 타입으로 받는 이 대입을 업캐스팅이라 한다.
Member m = new Admin("이서연", "보안");
m.introduce();
// 출력:
// 회원 이서연
// 담당: 보안팀
변수 타입은 Member인데 실행된 건 Admin이 덮어쓴 버전이다. 어떤 메서드가 불릴지는 변수에 적힌 타입이 아니라 그 안에 실제로 들어 있는 객체가 정한다. 이 성질을 다형성이라 부른다. 쓸모는 여러 종류를 한 그릇에 담을 때 드러난다.
List<Member> members = new ArrayList<>();
members.add(new Member("박지훈"));
members.add(new Admin("이서연", "보안"));
for (Member x : members) {
x.introduce();
}
// 출력:
// 회원 박지훈
// 회원 이서연
// 담당: 보안팀
반복문 쪽에는 if로 종류를 가르는 코드가 한 줄도 없다. 나중에 Member를 상속한 클래스가 열 개 더 늘어도 이 반복문은 손댈 데가 없다. 상속을 쓰는 실질적인 이유가 코드 재사용보다 이쪽에 가깝다.
부모 타입으로 담으면 자식에만 있는 기능은 안 보인다. 담긴 객체가 Admin이어도 변수 타입이 Member면 Member에 있는 것만 부를 수 있다.
Member m = new Admin("이서연", "보안");
System.out.println(m.getTeam()); // 컴파일 에러: cannot find symbol
컴파일러는 변수에 적힌 타입만 보고 판단하기 때문이다. 굳이 써야 한다면 원래 타입으로 되돌리는 다운캐스팅을 한다.
Admin back = (Admin) m;
System.out.println(back.getTeam()); // 보안
Member plain = new Member("박지훈");
Admin wrong = (Admin) plain;
// 실행 중 예외: ClassCastException: Member cannot be cast to Admin
다운캐스팅은 컴파일러가 막아 주지 않고 실행 중에 터진다. 안에 든 게 진짜 Admin일 때만 통하므로 확인하고 내려야 한다.
if (m instanceof Admin) {
Admin ad = (Admin) m;
System.out.println(ad.getTeam());
}
// 자바 16부터는 검사와 변환을 한 줄로
if (m instanceof Admin ad) {
System.out.println(ad.getTeam());
}
instanceof는 그 객체가 해당 타입인지 참거짓으로 알려 준다. 뒤에 변수 이름을 붙이면 검사에 통과했을 때 변환까지 끝난 변수를 바로 준다.
상속을 막고 싶으면 final. 클래스 앞에 final을 붙이면 아무도 상속할 수 없고, 메서드에 붙이면 자식이 덮어쓸 수 없다. String이 final 클래스라 String을 상속하는 코드는 컴파일되지 않는다. 반대로 자바의 모든 클래스는 아무것도 적지 않아도 Object를 상속한다. 어떤 객체든 toString을 가지고 있는 이유다. 다만 상속은 부모가 바뀌면 자식이 전부 흔들린다. 관리자는 회원이다처럼 말이 되는 관계일 때만 쓰고, 억지스러우면 그냥 필드로 갖고 있는 편이 낫다.
이번 편에서 손에 쥐어야 할 감각은 세 가지다. 자식 생성자는 super로 부모 몫을 먼저 채운다는 것, 오버라이딩은 이름과 인자가 완전히 같아야 하고 @Override가 그 실수를 잡아 준다는 것, 그리고 부모 타입 변수에 담아도 실행되는 메서드는 실제 객체가 정한다는 것이다. Member와 Admin을 직접 쳐 놓고 introduce를 덮었다 지웠다 하면서 출력이 어떻게 바뀌는지 보면 다형성이 손에 잡힌다.