상속과 Object — 모든 클래스의 부모
Article
11강에서 만든 Book 을 다시 본다.
class Book {
String title;
Book(String title) { this.title = title; }
}
Book a = new Book("자바");
Book b = new Book("자바");
System.out.println(a.equals(b));
- false
이상한 게 두 가지다.
하나는 false 라는 것. 다른 하나는 .equals() 를 부를 수 있다는 것이다. 우리는 Book 에 그런 메서드를 만든 적이 없다.
6강에서 처음 물었고, 9강에서 배열이 이상하게 굴 때 또 물었고, 11강에서 세 번째로 미뤘다. 이제 답한다.
물려받는다
먼저 상속부터 본다. 상품이 있고, 전자책은 상품의 한 종류다.
class Product {
protected String name;
Product(String name) {
this.name = name;
}
void describe() {
System.out.println("상품: " + name);
}
}
class Ebook extends Product {
int sizeMb;
Ebook(String name, int sizeMb) {
super(name);
this.sizeMb = sizeMb;
}
}
extends 는 "물려받는다"는 뜻이다. Ebook 은 name 필드도, describe() 메서드도 따로 안 만들었는데 갖고 있다.
Ebook e = new Ebook("자바", 12);
e.describe(); // 상품: 자바
super(name) 은 부모의 생성자를 부른다는 뜻이다. 부모가 자기 몫을 먼저 챙긴 다음 자식이 자기 것을 챙긴다. 생성자 첫 줄에만 올 수 있다.
protected 는 12강 표에 있던 그 단어다. "밖에서는 못 보지만 자식은 볼 수 있다"는 뜻이다. private 이었다면 Ebook 은 name 을 못 쓴다.
덮어쓴다
물려받은 메서드가 마음에 안 들면 다시 쓴다.
class Ebook extends Product {
int sizeMb;
Ebook(String name, int sizeMb) {
super(name);
this.sizeMb = sizeMb;
}
@Override
void describe() {
System.out.println("전자책: " + name + " (" + sizeMb + "MB)");
}
}
같은 이름, 같은 매개변수로 다시 정의하는 것을 오버라이딩이라고 한다. 10강의 오버로딩과 이름이 비슷해서 헷갈린다.
오버로딩
overloading · 10강
- 같은 클래스 안에서
- 이름은 같고 매개변수가 다르다
add(int, int)와add(double, double)- 컴파일러가 고른다
오버라이딩
overriding · 지금
- 부모와 자식 사이에서
- 이름도 매개변수도 똑같다
- 부모의
describe()를 자식이 다시 쓴다 - 실행할 때 정해진다
@Override 는 "이건 부모 것을 덮어쓰는 겁니다"라고 컴파일러에게 알리는 표시다. 없어도 동작하지만, 붙이면 이런 걸 잡아준다.
class Ebook extends Product {
@Override
void descrbe() { } // 오타
}
- error: method does not override or implement a method from a supertype
오타 하나로 부모의 메서드는 그대로 남고 자식에 엉뚱한 메서드가 하나 생길 뻔했다. @Override 가 막았다.
선언한 타입과 실제 타입
여기가 상속의 진짜 재미다.
Product p = new Ebook("자바", 12);
p.describe();
p 는 Product 타입으로 선언했다. Product 의 describe() 가 불릴 것 같다.
- 전자책: 자바 (12MB)
Ebook 것이 불렸다.
컴파일러가 보는 타입과 실행할 때의 타입이 다르다. 컴파일러는 p 를 Product 로 알고 있어서 describe() 를 부를 수 있는지만 확인한다. 실제로 어느 describe() 를 부를지는 JVM 이 실행 중에 상자 안의 진짜 객체를 보고 정한다.
상자의 이름표와 내용물이 다르다
- p
- Product 타입으로 선언
- → 실제로는 Ebook 객체
이 성질이 없으면 15강의 인터페이스도, Spring 같은 프레임워크도 존재할 수 없다. 지금은 "상자 이름표와 내용물이 다를 수 있다"만 기억하자.
모든 클래스의 부모
이제 첫 질문으로 돌아간다. 우리는 Book 에 equals 를 만들지 않았다.
자바의 모든 클래스는 아무것도 안 적어도 Object 를 상속한다.
class Book { }
// 컴파일러가 읽는 것: class Book extends Object { }
- Object
- String
- Integer
- Product
- Ebook
- Book
Object 가 갖고 있는 equals 는 이렇게 생겼다.
public boolean equals(Object obj) {
return (this == obj);
}
그냥 == 다. 주소를 비교한다.
그래서 우리 Book 은 false 를 냈고, 9강의 배열도 false 를 냈다. 아무도 덮어쓰지 않았기 때문이다.
String 은 다르다. String 을 만든 사람들이 equals 를 오버라이딩해서 글자를 비교하도록 바꿔놨다. 6강의 그 .equals() 는 특별한 문법이 아니라, 그냥 누가 덮어쓴 메서드였다.
toString() 도 마찬가지다. Object 의 toString() 은 클래스이름@주소 를 돌려준다. 9강의 [I@3af49f1c 도, 11강의 Book@681a9515 도 전부 이 메서드가 찍은 것이다.
우리 것도 덮어쓴다
Book 두 권을 제목으로 비교하고 싶다.
class Book {
String title;
Book(String title) { this.title = title; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Book other)) return false;
return Objects.equals(title, other.title);
}
@Override
public String toString() {
return "Book[" + title + "]";
}
}
한 줄씩 읽는다. this == o 면 같은 객체이니 바로 true. instanceof 는 "저 상자의 내용물이 Book 이냐"를 묻는다. 아니면 비교할 것도 없다.
o instanceof Book other 는 자바 16 부터 되는 문법이다. 검사에 통과하면 other 라는 이름으로 Book 타입 변수를 바로 만들어준다. 예전에는 (Book) o 로 손수 캐스팅해야 했다.
Objects.equals(a, b) 는 a 가 null 이어도 터지지 않는 안전한 비교다.
매개변수 타입이 Book 이 아니라 Object 인 것에 주의하자. 부모의 것을 덮어쓰려면 생김새가 똑같아야 한다. equals(Book o) 라고 쓰면 오버라이딩이 아니라 오버로딩이 되어버린다. @Override 가 이걸 잡아준다.
그런데 equals 만으로는 부족하다
이제 equals 는 true 를 낸다. 이걸로 끝일까.
Book a = new Book("자바");
Book b = new Book("자바");
System.out.println(a.equals(b));
Set<Book> set = new HashSet<>();
set.add(a);
set.add(b);
System.out.println("담긴 개수: " + set.size());
Set 은 중복을 허용하지 않는 상자다. 같은 책 두 권을 넣었으니 하나만 남아야 한다.
- true
- 담긴 개수: 2
equals 는 같다는데 Set 은 다르다고 한다.
HashSet 은 equals 를 부르기 전에 hashCode() 를 먼저 본다. 값을 숫자로 요약해서, 그 숫자가 같은 것들끼리만 equals 로 정밀 비교한다. 수만 개를 전부 비교하지 않으려는 요령이다.
Object 의 hashCode() 는 대체로 주소를 기반으로 숫자를 만든다. a 와 b 는 주소가 다르니 요약 숫자도 다르다. 애초에 같은 칸을 쳐다보지도 않았으므로 equals 는 불릴 기회조차 없었다.
그래서 규칙이 생긴다.
같이 덮어쓴다.
@Override
public int hashCode() {
return Objects.hash(title);
}
- true
- 담긴 개수: 1
Objects.hash(...) 에 equals 에서 비교한 필드를 그대로 넘기면 된다. equals 와 hashCode 는 같은 필드를 봐야 한다.
12강에서 record 가 equals 를 자동으로 만들어준다고 했다. 사실 hashCode 도 같이 만들어준다. 이제 왜 그게 편한지 알 수 있다.
이 이야기는 16강에서 HashMap 을 배울 때 완성된다. 지금은 규칙만 챙긴다.
정리하면
모든 클래스는 Object 를 상속하고, equals 는 원래 == 였다.
- 01
extends로 물려받고super(...)로 부모 생성자를 부른다protected 는 자식에게만 열어주는 문이다.
- 02
오버라이딩은 실행할 때 정해진다
선언한 타입이 아니라 상자 안의 진짜 객체를 보고 메서드를 고른다.
Product p = new Ebook("자바", 12); p.describe(); // Ebook 의 것이 불린다 - 03
모든 클래스의 부모는
Object다equals 는 원래 == 였고, toString 은 원래 클래스이름@주소 였다.
- 04
equals를 덮어쓸 때@Override를 붙인다매개변수는 Object 다. Book 이라고 쓰면 오버로딩이 되어버린다.
@Override public boolean equals(Object o) { ... } - 05
equals를 덮어쓰면hashCode도 덮어쓴다안 그러면 HashSet 과 HashMap 이 틀린 답을 낸다. 컴파일러는 안 잡아준다.
Part 4 가 끝났다. 값과 코드를 한 덩어리로 묶고(11강), 그 덩어리에 자물쇠를 채우고(12강), 객체 없이 존재하는 것을 구분하고(13강), 모든 클래스가 하나의 부모에서 나왔다는 것을 봤다.
2강의 다섯 단어도 전부 회수했다.
다음 Part 에서는 상속을 한 단계 더 민다. Ebook 이 Product 를 물려받는 대신, 아무것도 물려받지 않고 약속만 지키게 하면 어떻게 될까.
그 약속을 인터페이스라고 부른다. 그리고 방금 본 "선언한 타입과 실제 타입이 다르다"는 성질이, 거기서 프로그램의 구조를 통째로 바꾼다.