자바가 제공하는 다중 구현 메커니즘으로 interface와 abstract class가 존재한다.
자바 8부터는 인터페이스에 default method를 지원하여 두 메커니즘 모두 인스턴스 메서드를 구현 형태로 제공할 수 있다.
둘의 가장 큰 차이는 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다. 이는 단일 상속만 지원하는 자바의 한계로 인해, 추상 클래스 방식은 새로운 타입 정의에 커다란 제약을 지게 된다.
하지만 인터페이스는 어떤 클래스를 상속했든 같은 타입으로 취급한다.
- 인터페이스 : 다중 상속이 가능하고 구현한 클래스와 같은 타입으로 취급. java8부터 default 메서드 제공
- 추상클래스 : 다중 상속 불가, 구현체와 상화관계에 있음.
📌 인터페이스가 확장에 용이한 이유
1️⃣ 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.
- 인터페이스 : 인터페이스의 추상 메서드를 추가하고, 클래스에 implements 구문을 추가하여 구현체임을 알린다.
- 추상클래스 : 계층 구조상 두 클래스의 공통 조상이어야 하며, 새로 추가된 추상 클래스의 모든 자손이 상속한다.
기존 클래스 위에 새로운 추상 클래스를 끼워넣기는 어려운 게 일반적이다.
두 클래스가 같은 추상 클래스를 확장하길 원한다면 위의 방식을 따라야 하나, 안타깝게도 이 방식은 클래스 계층 구조에 커다란 혼란을 일으킨다.
2️⃣ 인터페이스는 믹스인(mixin) 정의에 안성맞춤이다.
✒️ 믹스인(mixins)
객체지향 언어에서 다른 클래스에서 사용할 목적으로 만들어진 클래스이다.
composition 혹은 aggregation이라고 불리기도 하는데, Item 18에서 배운 컴포지션을 참고하면 된다.
대상 타입의 주된 기능에 선택적 기능을 혼합(mixed in)한다고 해서 믹스인이라고 불린다.
자바 코드에서는 다중 상속의 제한이 없는 인터페이스로 구현하기 용이하다.
예를 들어 Comparable은 자신을 구현한 클래스의 인스턴스끼리는 순서를 정할 수 있다고 선언하는 전형적인 mixin 인터페이스다.
추상 클래스는 단일 상속만 가능하므로, 클래스 계층 구조에서는 기존 클래스를 덧씌울 수 없다.
3️⃣ 인터페이스로는 계층구조가 없는 타입 프레임 워크를 만들 수 있다.
// 인터페이스로 구현
public interface Singer {
AudioClip sing(Song s);
}
public interface SongWriter {
Song compose(int charPosition);
}
public interface SingSongWriter extends Singer, SongWriter {
AudioClip strum();
void actSensitive();
}
// 추상 클래스로 구현
public abstract class Singer {
abstract AudioClip sing(Song s);
}
public abstract class SongWriter {
abstract Song compose(int charPosition);
}
public abstract class SingerSongWriter {
abstract AudioClip sing(Song s);
abstract Song compose(int charPosition);
abstract AudioClip strum();
abstract void actSensitive();
}
추상 클래스로 만들면 다중 상속이 불가하여 새로운 추상 클래스를 만들어서 클래스 계층을 표현할 수밖에 없다.
하지만 인터페이스는 클래스로 구현하면 속성이 n개(2^n개 조합)여서 조합 폭발(combinatorial explosion)이 일어날 상황조차 유연하게 대응할 수 있다.
4️⃣ 기능을 향상시키는 안전하고 강력한 도구가 된다.
래퍼 클래스 관용구(컴포지션)와 함께 사용하면 강력한 시너지 효과를 낼 수 있다.
타입을 추상 클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이다. 상속해서 만든 클래스는 래퍼 클래스보다 활용도가 떨어지고 깨지기는 더 쉽다.
📌 디폴트 메서드 제약
자바8부터 인터페이스에서도 메서드를 구현할 수 있게 되었다. 다만 아래 규칙을 지켜야 한다.
- @implSpec 자바독 태그를 붙여 사용하려는 default 메서드를 문서화한다.
- equals와 hashCode는 default 메서드로 제공해서는 안 된다.
- 인스턴스 필드를 가질 수 없다.
- public이 아닌 정적 멤버를 가질 수 없다.
- 만들지 않은 인터페이스에는 디폴트 메서드를 추가할 수 없다.
이 기법의 예를 잘 보여주고 있는 removeIf 메서드다.
/**
* Removes all of the elements of this collection that satisfy the given
* predicate. Errors or runtime exceptions thrown during iteration or by
* the predicate are relayed to the caller.
*
* @implSpec
* The default implementation traverses all elements of the collection using
* its {@link #iterator}. Each matching element is removed using
* {@link Iterator#remove()}. If the collection's iterator does not
* support removal then an {@code UnsupportedOperationException} will be
* thrown on the first matching element.
*
* @param filter a predicate which returns {@code true} for elements to be
* removed
* @return {@code true} if any elements were removed
* @throws NullPointerException if the specified filter is null
* @throws UnsupportedOperationException if elements cannot be removed
* from this collection. Implementations may throw this exception if a
* matching element cannot be removed or if, in general, removal is not
* supported.
* @since 1.8
*/
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean removed = false;
final Iterator<E> each = iterator();
while (each.hasNext()) {
if (filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
📌 추상 골격 클래스 (Skeletal Implementation)
인터페이스와 추상 클래스의 장점을 모두 취하는 관점이다.
디자인 패턴으로는 템플릿 메서드 패턴이다.
✒️ 템플릿 메서드 패턴
부모 클래스에서 알고리즘의 골격을 정의하지만, 해당 알고리즘 구조를 변경하지 않고 자식 클래스들이 알고리즘 특정 단계들을 Override할 수 있도록 하는 행동 디자인 패턴이다.
알고리즘을 일련의 단계로 나누고, 이러한 단계들을 메서드들로변환한 뒤, 단일 템플릿 메서드 내부에 이러한 메서드들에 대한 일련의 호출들을 넣는다.
이 단계들은 abstract이거나 일부 default 구현을 가질 것이고, 알고리즘을 사용하기 위해 클라이언트는 자신의 자식 클래스를 제공 및 모든 추상 단계를 구현해야 한다. 필요하다면 (템플릿 메서드를 제외한) 선택적 단계 중 일부를 재정의해야 할 수도 있다.
인터페이스로는 타입을 정의하고, 필요하면 디폴트 메서드 몇 개도 함께 제공한다.
그리고 골격 구현 클래스는 나머지 메서드들까지 구현한다.
이렇게 해두면 단순히 골격 구현을 확장하는 것만으로 이 인터페이스를 구현하는데 필요한 일이 대부분 완료된다.
관례상 인터페이스 이름이 Interface라면, 골격 구현 클래스의 이름은 AbstractInterface로 짓는다.
좋은 예로, AbstractCollection, AbstractSet, AbstractList, AbstractMap 각각이 바로 핵심 컬렉션 인터페이스의 골격 구현이다.
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {...}
static List<Integer> intArrayAsList(int[] a) {
Objects.requireNonNull(a);
return new AbstractList<>() {
@Override public Integer get(int i) {
return a[i]; // auto boxing
}
@Override public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // auto boxing
return oldVal; // auto unboxing
}
@Override public int size() {
return a.length;
}
}
}
위 코드는 골격 구현을 사용해 완성한 구체 클래스다. (완벽히 동작하는 List 구현체를 반환하는 정적 팩터리 메서드로, AbstractLIst 골격 구현으로 활용했다.)
- 사용자는 몇 가지 오버라이딩 메서드만을 통해 원하는 바를 구현했다.
- int 배열을 받아 Integer 인스턴스의 리스트 형태로 보여주는 Adapter이기도 하다.
- 박싱과 언박싱으로 인해 성능은 그리 좋지 않다.
- 익명 클래스를 사용했다.
💡 골격 구현 클래스는 추상 클래스처럼 구현을 도와주는 동시에 추상 클래스로 타입을 정의할 때 따라오는 심각한 제약에서는 자유롭다. 인터페이스 디폴트 메서드가 갖는 한계를 추상클래스를 이용해서 벗어난다.
대충 내 생각을 정리해보기 위한 예시 코드..
// interface
public interface Car {
void on();
void break();
void acc();
void off();
}
// abstract skeletal class
public abstract class AbstractCar implements Car {
// 같은 동작하는 메서드 정의
@Override public void on() {
...
}
@Override public void break() {
...
}
@Override public void off() {
...
}
}
// Implementation
public class MyCar extends AbstractCar implements Car {
@Override public void acc() {
System.out.println("내 속도는 400km로~");
}
}
📌 시뮬레이트한 다중 상속 (Simulated Multiple Inheritance)
구조상 골격 구현을 확장하지 못하는 처지(상속은 하나만 가능하다는 제약)라면 인터페이스를 직접 구현해야 한다.
이런 경우라도 인터페이스가 직접 제공하는 디폴트 메서드의 이점을 여전히 누릴 수 있다.
또한, 골격 구현 클래스를 우회적으로 이용할 수도 있다.
인터페이스를 구현한 클래스에서 해당 골격 구현을 확장한 private 내부 클래스를 정의하고, 각 메서드 호출을 내부 클래스의 인스턴스에 전달하는 것이다. (Item 18에서 다룬 방식과 비슷하다)
다중 상속의 많은 장점을 제공하는 동시에 단점은 피하게 해준다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) {
this.s = s;
}
public void clear() {
s.clear();
}
...
}
아래는 공부용 예시 코
public class CarManuFactory {
public void printManuFactory() {
System.out.println("Made by Korea");
}
}
// 골격 구현을 확장
public class InnerAbstractCar extends AbstractCar {
@Override void acc() {
System.out.println("Yes, I am your Car");
}
}
// Implementation
public class MyCar extends CarManuFactory implements Car {
InnerAbstractCar innerAbstractCar = new InnerAbstractCar();
@Override public void on() {
innerAbstractCar.on();
}
@Override public void break() {
innerAbstractCar.break();
}
@Override public void off() {
innerAbstractCar.off();
}
@Override public void acc() {
printManuFactory();
innerAbstractCar.acc();
}
}
📌 골격 구현 작성 순서
- 다른 메서드들의 구현에 사용되는 기반 메서드 선정 (이 기반 메서드들은 골격 구현에서는 추상 메서드가 될 것이다.)
- 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 모두 디폴트 메서드로 제공
- 단, equals(), hashCode()는 제공하면 안 된다.
- 만약 인터페이스의 메서드 모두가 기반 메서드와 디폴트 메서드가 된다면 골격 구현 클래스를 별도로 만들 이유는 없다.
- 기반 메서드나 디폴트 메서드로 만들지 못한 메서드가 남아 있다면, 인터페이스를 구현하는 골격 구현 클래스를 만들어 남은 메서드를 작성 (골격 구현 클래스에는 필요하면 public이 아닌 필드와 메서드 추가해도 된다.)
- 골격 구현은 기본적으로 상속이므로, 설계 및 문서화 지침을 따라야 한다.
간단한 예로 Map.Entry 인터페이스를 살펴보자.
public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {
// 변경 가능한 엔트리는 이 메서드를 반드시 재정의해야 한다.
@Override public V setValue(V value) {
throw new UnsupportedOperationException();
}
// Map.Entry.equals의 일반 규약을 구현한다.
@Override public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof Map.Entry)) {
return false;
}
Map.Entry<?, ?> e = (Map.Entry) obj;
return Objects.equals(e.getKey(), getKey()) && Objects.equals(e.getValue(), getValue());
}
// Map.Entry.hashCode의 일반 규약을 구현한다.
@Override public int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
@Override public String toString() {
return getKey() + "=" + getValue();
}
}
- getKey, getValue는 확실히 기반 메서드이며, 선택적으로 setValue도 포함할 수 있다.
- 이 인터페이스는 equals와 hashCode의 동작 방식도 정의해놨다.
- Object메서드들은 디폴트 메서드로 제공해서는 안 되므로, 해당 메서드들은 모두 골격 구현 클래스에 구현한다.
- toString도 기반 메서드를 사용해 구현해놨다.
💡 Map.Entry 인터페이스나 그 하위 인터페이스로는 이 골격 구현을 제공할 수 없다. 디폴트 메서드는 equals, hashCode, toString 같은 Object 메서드를 재정의할 수 없기 때문이다.
📌 단순 구현 (Simple Implementation)
골격 구현의 작은 변종이다.
단순 구현도 상속을 위해 인터페이스를 구현한 것이긴 한데, 추상 클래스가 아니란 점이 다르다.
(쉽게 말해 동작하는 가장 단순한 구현이다.)
예를 들어 AbstractMap.SimpleEntry가 있다.
이러한 단순 구현은 그대로 써도 되고 필요에 맞게 확장해도 된다.