아이템 20. 추상 클래스보다는 인터페이스를 우선하라
자바 8부터 인터페이스도 디폴트 메서드(default method)를 제공할 수 있다.
자바가 제공하는 다중 구현 메커니즘은 인터페이스, 추상 클래스 이렇게 두 가지다.
자바 8부터 인터페이스도 디폴트 메서드(default method)를 제공할 수 있게 되어 이제는 두 메커니즘 모두 인스턴스 메서드를 구현 형태로 제공할 수 있다.
public interface TimeClient {
void setTime(int hour, int minute, int second);
void setDate(int day, int month, int year);
void setDateAndTime(int day, int month, int year,
int hour, int minute, int second);
LocalDateTime getLocalDateTime();
static ZoneId getZonedId(String zoneString) {
try {
return ZoneId.of(zoneString);
} catch (DateTimeException e) {
System.err.println("Invalid time zone: " + zoneString + "; using default time zone instead.");
return ZoneId.systemDefault();
}
}
default ZonedDateTime getZonedDateTime(String zoneString) {
return ZonedDateTime.of(getLocalDateTime(), getZonedId(zoneString));
} // 인터페이스를 진화시킬 수 있다.
}
구현이 명백한 것은 인터페이스의 디폴트 메서드를 사용해 프로그래머의 일감을 덜어줄 수 있다.
추상클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다. 자바는 단일 상속만을 지원하므로 추상클래스 방식은 새로운 타입을 정의하는 데 커다란 제약을 안게 된다.
기존 클래스에도 손쉽게 새로운 인터페이스를 구현해넣을 수 있다.
인터페이스는 클래스 선언에 implements 구문만 추가하면 끝이다.
반면 기존 클래스 위에 새로운 추상 클래스를 끼워넣기는 어려운 게 일반적이다. 두 클래스가 같은 추상 클래스를 확장하길 원한다면, 그 추상 클래스는 계층구조상 두 클래스의 공통 조상이어야 한다.
인터페이스는 믹스인(mixin)정의에 안성맞춤이다.
원래의 '주된 타입'외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 준다.
public class SimpleTimeClient implements TimeClient, Comparable {
// ... 코드생략
@Override
public int compareTo(Object o) {
// ... 코드생략
}
}
Comparable은 자신을 구현한 클래스의 인스턴스끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스다. 대상 타입의 주된 기능에 선택적 기능을 '혼합(mixed in)' 한다고 해서 믹스인이라 부른다.
인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
타입을 계층적으로 정의하면 수많은 개념을 구조적으로 잘 표현할 수 있지만, 현실에는 계층을 엄격히 구분하기 어려운 개념도 있다.
public interface SingerSongwriter extends Singer, Songwriter { // Singer, Songwriter <= 계층구조가 없음.
AudioClip strum();
void actSensitive();
}
래퍼 클래스(아이템 18)와 함께 사용하면 인터페이스는 기능을 향상 시키는 안전하고 강력한 수단이 된다.
추상클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이다. 상속해서 만든 클래스는 래퍼 클래스보다 활용도가 떨어지고 깨지기는 더 쉽다.
인터페이스와 추상 골격 구현(skeletal implementation) 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취할 수 있다.
인터페이스 - 타입 정의, 디폴트 메서드 구현
추상 골격 클래스 - 나머지 메서드 구현
단순히 골격 구현을 확장하는 것만으로 이 인터페이스를 구현하는 데 필요한 일이 대부분 완료된다. => 템플릿 메서드 패턴
관례상 인터페이스 이름이 Interface라면 그 골격 구현 클래스의 이름은 AbstractInterface로 짓는다. 컬렉션 프레임워크의 AbstractCollection, AbstractSet, AbstractList, AbstractMap 각각이 바로 핵심 컬렉션 인터페이스의 골격 구현이다.
public class IntArrays {
static List<Integer> intArrayAsList(int[] a) { // int 배열을 받아 Integer 인스턴스의 리스트 형태로 보여주는 어댑터(Adapter)이기도 하다.
Objects.requireNonNull(a);
// 다이아몬드 연산자를 이렇게 사용하는 건 자바 9부터 가능하다.
// 더 낮은 버전을 사용한다면 <Integer>로 수정하자.
return new AbstractList<>() {
@Override public Integer get(int i) {
return a[i]; // 오토박싱(아이템 6)
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // 오토언박싱
return oldVal; // 오토박싱
}
@Override public int size() {
return a.length;
}
};
}
}
만약 AbstractList를 사용하지 않고 List를 쓰려고 했다면, 이렇게나 많은 메서드를 구현해야한다. Skeleton 역할을 하는 AbstractList를 사용하여 아주 일부분만 재정의할 수 있다.
시뮬레이트한 다중 상속 (simulated multiple inheritance)
인터페이스를 구현한 클래스에서 해당 골격 구현을 확장한 private 내부 클래스를 정의하고, 각 메서드 호출을 내부 클래스의 인스턴스에 전달한다. 래퍼 클래스와 비슷한 이 방식은 다중 상속의 많은 장점을 제공하는 동시에 단점은 피하게 해준다.
public abstract class AbstractCat {
protected abstract String sound();
protected abstract String name();
}
public class MyCat extends AbstractCat {
@Override
protected String sound() {
return "인싸 고양이 두 마리가 나가신다!";
}
@Override
protected String name() {
return "유미";
}
public static void main(String[] args) {
MyCat myCat = new MyCat();
System.out.println(myCat.sound());
System.out.println(myCat.name());
}
}
AbstractCat을 상속받은 MyCat이 있다.
▼ Flyable interface
public interface Flyable {
void fly();
}
▼ AbstractFlyable
public class AbstractFlyable implements Flyable {
@Override
public void fly() {
System.out.println("너랑 딱 붙어있을게!");
}
}
MyCat 클래스가 이미 AbstractCat을 상속받고 있기 때문에, AbstractFlyable 을 추가로 상속 받지 못한다.
public class MyCat extends AbstractCat implements Flyable {
private MyFlyable myFlyable = new MyFlyable();
@Override
protected String sound() {
return "인싸 고양이 두 마리가 나가신다!";
}
@Override
protected String name() {
return "유미";
}
public static void main(String[] args) {
MyCat myCat = new MyCat();
System.out.println(myCat.sound());
System.out.println(myCat.name());
myCat.fly();
}
@Override
public void fly() {
this.myFlyable.fly();
}
private class MyFlyable extends AbstractFlyable {
@Override
public void fly() {
System.out.println("날아라.");
}
}
}
1) Flyable interface를 구현하고 2) private class를 AbstractFlyable을 상속을 받는다. 3) Flyable interface 를 구현한 메서드에, 선언한 private MyFlyable myFlyable 필드의 메서드를 위임하면 마치 상속을 한 것처럼 사용할 수 있다.
템플릿 메서드 패턴
알고리즘 구조를 서브 클래스가 확장할 수 있도록 템플릿으로 제공하는 방법이다. 추상 클래스는 템플릿을 제공하고 하위 클래스는 구체적인 알고리즘을 제공한다.
▼ Abstract class
public abstract class FileProcessor {
private String path;
public FileProcessor(String path) {
this.path = path;
}
public final int process() { // template method
try(BufferedReader reader = new BufferedReader(new FileReader(path))) {
int result = 0;
String line = null;
while((line = reader.readLine()) != null) {
result = getResult(result, Integer.parseInt(line));
}
return result;
} catch (IOException e) {
throw new IllegalArgumentException(path + "에 해당하는 파일이 없습니다.", e);
}
}
protected abstract int getResult(int result, int number);
}
▼ Concrete class
public class Plus extends FileProcessor {
public Plus(String path) {
super(path);
}
@Override
protected int getResult(int result, int number) {
return result + number;
}
}
▼ client
public class Client {
public static void main(String[] args) {
FileProcessor fileProcessor = new Plus("number.txt");
System.out.println(fileProcessor.process());
}
}
템플릿 콜백 패턴
Plus 클래스는 FileProcessor를 상속받았다. 상속을 사용하지 않고도 확장할 수 있다.
public class FileProcessor {
private String path;
public FileProcessor(String path) {
this.path = path;
}
public final int process(BiFunction<Integer, Integer, Integer> operator) {
try(BufferedReader reader = new BufferedReader(new FileReader(path))) {
int result = 0;
String line = null;
while((line = reader.readLine()) != null) {
result = operator.apply(result, Integer.parseInt(line));
}
return result;
} catch (IOException e) {
throw new IllegalArgumentException(path + "에 해당하는 파일이 없습니다.", e);
}
}
// protected abstract int getResult(int result, int number);
}
BiFunction은 두 개의 인자를 받아서 하나의 인자값을 돌려주는 오퍼레이션을 정의할 수 있는 함수형 인터페이스이다. BiFunction을 인자로 받으면서 FileProcessor를 추상클래스가 아닌 클래스로 정의한다.
public class Client {
public static void main(String[] args) {
FileProcessor fileProcessor = new FileProcessor("number.txt");
System.out.println(fileProcessor.process((a, b) -> a + b));
System.out.println(fileProcessor.process(Integer::sum)); // method reference
}
}
Client에서 FileProcessor의 processor를 호출하며 (a, b) -> a + b를 넘겨주면 된다. 이것을 method reference로 간추릴 수도 있다.
default 메서드는 equals, hashCode, toString과 같은 Object 메서드를 재정의할 수 없다.
Object 클래스를 오버라이딩하려고 하면 "Default method 'toString' overrides a memeber of 'java.lang.Object'" 컴파일 에러가 발생한다.
왜 자바 개발자들은 오버라이딩을 막았을까?
1) The key goal of adding default methods to Java was "interface evolution", not "poor man's traits."
default 메서드의 용도가 아니다.
2) Adds complexity.
복잡도를 증가시킨다.
Rule #1. Classes win over interfaces.
=> 클래스가 인터페이스를 이긴다.
Rule #2. More specific interfaces win over less specific ones (where specificity means "subtyping").
=> 더 구체적인 인터페이스가 덜 구체적인 인터페이스를 이긴다.
만약 interface의 default메서드에서 Object 메서드를 오버라이딩하게되면 이 두가지 규칙 중 무엇을 따라야할 지 복잡해진다.
3) Really only makes sense in toy examples.
사실상 toy project에서나 필요한 기능이다. toString, hashCode, equals 기능은 데이터가 있어야 유의미한 기능이라 인터페이스에서 재정의했을 때 의미가 없다.
4) It's brittle.
깨지기 쉽다. 갑자기 구현하고 있는 Interface에서 Object 메서드들을 오버라이딩했을 때, 클래스의 동작이 바뀌게 된다.