본문 바로가기

Reading/Effective Java

[Effective-Java] Item 20. 추상 클래스보다는 인터페이스를 우선하라

자바가 제공하는 다중 구현 메커니즘은 인터페이스와 추상 클래스가 있다.

자바 8부터 인터페이스도 default method를 제공할 수 있어 두 메커니즘 모두 인스턴스 메소드를 구현 형태로 제공할 수 있다.

추상 클래스를 정의한 타입을 구현하는 클래스는 상속을 해야하는데, 자바가 단일 상속만 지원하니 추상 클래스 방식은 새 타입 정의에 제약이 있는 반면 인터페이스가 선언한 메소드를 모두 정의하고 규약을 잘 지킨 클래스면 어떤 클래스를 상속했든 같은 타입으로 취급된다.

 

인터페이스의 장점


 

1. 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해넣을 수 있다.

인터페이스가 요구하는 메소드를 추가하고 클래스 선언에 implements 구문만 추가하면 끝이다.

반면 기존 클래스 위에 새로운 추상 클래스를 끼워넣기는 어렵다. 두 클래스가 같은 추상 클래스를 확장하려 한다면, 그 추상 클래스는 계층 구조상 두 클래스의 공통 조상이어야한다.

이 모든 하위 클래스는 상속하는게 적절하지 않은 상황에서도 강제로 상속하게 되고, 클래스 계층 구조에 혼란을 일으킨다.

 

2. 인터페이스는 믹스인(mixin) 정의에 안성맞춤이다.

믹스인(mixin): 대상 타입의 주된 기능에 선택적 기능을 혼합(mixed in) 하는 것

ex) Comparable은 자신을 구현한 클래스의 인스턴스끼리 순서를 정할 수 있게 선언

추상 클래스로는 기존 클래스에 덧 씌울 수 없고, 다중상속이 안되며, 클래스 계층구조엔 믹스인 삽입에 적당한 위치가 없다. 

 

3. 인터페이스로는 계층 구조가 없는 타입 프레임워크를 만들 수 있다.

타입을 계층적으로 정의하면 수많은 개념을 구조적으로 잘 표현할 수 있지만, 계층을 엄격히 구분하기 어려운 개념도 있다.

아래 코드처럼 타입을 인터페이스로 정의하면 Singer와 Songwriter 모두 구현, 확장, 새 메소드를 추가한 인터페이스를 정의해도 된다.

 

interface Singer{
    void sing(String s);
}
interface Songwriter{
    void compose(int chartPosition);
}
interface SingerSongwriter extends Singer, Songwriter{
    void strum();
    void actSensitive();
}

 

 

 

하지만 같은 쿠조를 클래스로 만들려면 가능한 조합 전부를 각각의 클래스로 정의한 계층구조가 만들어진다.

조합폭발(속성이 n개라면 지원해야 할 조합의 수는 2^n개)이 발생하며 거대한 클래스 계층구조에는 공통 기능을 정의해놓은 타입이 없으니 매개변수 타입만 다른 메소드들을 수없이 많이 가진 거대한 클래스가 발생할 수 있다. 다중상속이 안되니 아래처럼 구현해야한다.

 

abstract class Singer{
    abstract void sing(String s);
}
abstract class Songwriter{
    abstract void compose(int chartPosition);
}
abstract class SingerSongwriter{
    abstract void sing(String s);
    abstract void compose(int chartPosition);
    abstract void strum();
    abstract void actSensitive();
}

 

4. 래퍼 클래스 관용구(아이템 18)와 함께 사용하면 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.

타입을 추상 클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이고, 상속해서 만든 클래스는 래퍼 클래스보다 활용도가 떨어지고 깨지기 쉽다.

 

 

 

인터페이스 메소드 중 구현 방법이 명백하다면 디폴트 메소드로 제공하자


다음 코드는 Collection 인터페이스의 removeIf default method의 예시다. 구현체에서 재정의하는 것은 자유다.

디폴트 메소드를 제공할 땐 아이템 19처럼 @implSpec 자바독 태그를 붙혀 문서화 해야한다. 

 

 

많은 인터페이스가 equals와 hashCode같은 Object의 메소드를 정의하고 있는데, 이들은 디폴트 메소드로 제공해서는 안된다.

 

인터페이스 내에서 equals는 추상메소드는 되지만, 디폴트 메소드로는 선언할 수 없다.

 

그리고 인터페이스는 인스턴스 필드를 가질 수 없고 public이 아닌 정적 멤버도 가질 수 없다.

정적 멤버는 int a = 1; 로 선언해도 public static final int a = 1로 컴파일 과정에서 변환된다.(private 정적 메소드는 예외)

그리고 만들지 않은 인터페이스에는 디폴트 메소드를 추가할 수 없다.

 

 

인터페이스와 추상 골격 구현(skeletal implementation) 클래스 함께 제공하기


 

인터페이스와 추상 클래스의 장점을 모두 취할 수 있다.

인터페이스로는 타입을 정의하고 필요하다면 디폴트 메소드도 추가한다. 그리고 골격 구현 클래스는 나머지 메소드들까지 구현한다.

이렇게 해두면 단순히 골격 구현을 확장하는 것 만으로 이 인터페이스를 구현하는데 필요한 게 대부분 완성되는 템플릿 메소드 패턴이다.

 

관례상 인터페이스 이름이 Interface면, 골격 구현 클래스의 이름은 AbstractInterface로 한다.(AbstractCollection,AbstractMap..)

골격 구현은 독립된 추상 클래스로든 디폴트 메소드로 이뤄진 인터페이스로든 그 인터페이스로 구현해 필요한 일을 상당히 덜어준다.

 

List 구현체를 반환하는 정적 메소드로, 익명클래스를 통해 AbstractList 골격 구현을 활용한 예를 보자.

 

static List<Integer> intArrayAsList(int[] a) {
    Objects.requireNonNull(a);
    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;
        }
    };
}

 

이 예는 int 배열을 받아 Integer 인스턴스의 리스트 형태로 보여주는 어댑터이기도 하다.(물론 박싱/언박싱 때문에 성능은 별로)

물론 여기서 구현하지 않은 항목들은 사용할 수 없게 예외를 던지게 되어있다. AbstractList의 remove 메소드를 보면 알 수 있다.

 

 

만약 억지로 사용하게 되는걸 방지해 컴파일 에러 대신 사용 시점에 UnsupportedOperationException를 던지게 되어있다.

 

골격 구현 클래스는 추상 클래스처럼 구현을 도와주는 동시에, 추상 클래스로 타입을 정의할 때 따라오는 심각한 제약에서 해방시켜준다.

골격 구현을 확장하는 것으로 인터페이스 구현이 거의 끝나지만, 구조상 골격 구현을 확장하지 못한다면 인터페이스를 직접 구현해야한다.

즉, 확장을 이미 한 클래스의 경우 구현을 직접 해줘야하는 경우다. ex) public class A extends B implements C--> + implements D

이런 경우라도 인터페이스가 직접 제공하는 디폴트 메소드의 이점을 여전히 누릴 수 있다.

 

또한 골격 구현 클래스를 우회적으로 이용할 수도 있다.

인터페이스를 구현한 클래스에서 해당 골격 구현을 확장한 private 내부 클래스를 정의 후, 각 메소드 호출을 내부 클래스의 인스턴스에 전달하는 것이다. 아이템 18의 래퍼 클래스와 비슷한 이 방식을 시뮬레이트한 다중 상속(simulated multiple inheritance)이라 한다.

([아이템18] InstrumentedSet 클래스 참조)

 

아래 사이트를 참조했고, 더 자세한 설명이 필요하다면 아래 참조사이트를 활용하자.

 

 

Favor Skeletal Implementation in Java - DZone Java

 

dzone.com

 

 

 

// 코드(1)
interface Car{
    void run();
    void stop();
}
class SuperCar implements Car{
    @Override
    public void run() {
        System.out.println("SuperCar run!");
    }

    @Override
    public void stop() {
        System.out.println("멈춰!");
    }
}
class GeneralCar implements Car{
    @Override
    public void run() {
        System.out.println("GenericCar run!");
    }

    @Override
    public void stop() {
        System.out.println("멈춰!");
    }
}

 

자동차 인터페이스를 구현하는 슈퍼차와 일반차다. run 메소드만 다르고 stop 메소드는 메소드 내용이 동일하기 때문에 중복이 생긴다.

골격 구현을 통해 추상 클래스 AbstractCar를 구현해보자.

 

// 코드(2)
interface Car{
    void run();
    void stop();
}
abstract class AbstractCar implements Car{
    @Override
    public void stop() {
        System.out.println("멈춰!");
    }

    @Override
    public void run() {
        throw new RuntimeException();
    }
}

class SuperCar extends AbstractCar implements Car{
    @Override
    public void run() {
        System.out.println("SuperCar run!");
    }

}
class GeneralCar extends AbstractCar implements Car{
    @Override
    public void run() {
        System.out.println("GenericCar run!");
    }
}

 

메소드간의 중복을 추상클래스 골격 구현인 AbstractCar로 구현해서 상속받아 사용함을 알 수 있다.

그리고 run,process를 구현하지 않은 하위 클래스는 사용할 수 없도록 런타임 에러를 던진다!

하지만 차가 출발할 때 SuperCar는 제조사가 정비 서비스를 제공한다고 가정해보자.

이미 골격 구현을 확장했기 때문에 슈퍼차와 일반차는 더이상 확장할 수 없다.

 

코드 설명 전, 상속을 더 이상 할 수 없을 경우 인터페이스를 직접 구현하는 방법도 있다고 했다.

이 경우 중복을 없애기 위해 stop 메소드만 디폴트 메소드로 바꾸면 된다. 골격 구현 작성 메뉴얼의 2번 과정에서 끝날 경우!

 

하지만 메소드가 남아있을 수 있으니 골격 클래스를 우회하여 골격 구현을 확장한 private 내부 클래스를 정의하는 방법을 보자.

 

// 코드(3)
class TeslxCar{
    public void repair(){
        System.out.println("테슬x 정비 시작");
    }
}

interface Car{
    void run();
    void stop();
    void process();
}
abstract class AbstractCar implements Car{
    @Override
    public void stop() {
        System.out.println("멈춰!");
    }

    @Override
    public void run() {
        throw new RuntimeException();
    }

    @Override
    public void process() {
        throw new RuntimeException();
    }
}

class SuperCar extends TeslxCar implements Car{
    InnerAbstractCar innerAbstractCar = new InnerAbstractCar();
    @Override
    public void run() {
        innerAbstractCar.run();
    }

    @Override
    public void stop() {
        innerAbstractCar.stop();
    }

    @Override
    public void process() {
        repair();
        innerAbstractCar.run();
    }
    private static class InnerAbstractCar extends AbstractCar{
        @Override
        public void run() {
            System.out.println("SuperCar run!");
        }
    }
}
class GeneralCar extends AbstractCar implements Car{
    @Override
    public void run() {
        System.out.println("GenericCar run!");
    }
}

 

private 내부 클래스를 정의하고 각 메소드 호출을 내부 클래스의 인스턴스로 전달한다.

SuperCar는 TeslxCar를 상속받아서 본래 의도대로 정비를 하고, 내부 private 클래스가 스켈레톤 구현을 상속해서

run을 SuperCar에 맞게 재정의하고 run 메소드를 가져다 쓴다. 뿐만아니라 이 내부 private 클래스를 통해 stop도 쓴다. 

 

 

 

골격 구현 작성 메뉴얼


 

1. 인터페이스에서 다른 메소드들의 구현에 사용되는 기반 메소드를 선정한다.

이 기반 메소드들은 골격 구현에서 추상 메소드가 된다.

 

2. 기반 메소드들을 사용해 직접 구현할 수 있는 메소드를 모두 디폴트 메소드로 제공한다.

equals와 hashCode 같은 Object의 메소드는 디폴트 메소드로 제공할 수 없다는 것만 기억하자.

그리고 만약 인터페이스의 메소드 모두가 기반 메소드와 디폴트 메소드가 되면 골격 구현 클래스를 별도로 만들 이유는 없다.

 

3. 메소드가 남아 있다면 이 인터페이스를 구현하는 골격 구현 클래스를 하나 만들어 남은 메소드들을 작성한다.

골격 구현 클래스에서 필요하면 public이 아닌 필드와 메소드를 추가해도 된다.

 

Map.Entry 인터페이스를 보면 getKey, getValue는 확실히 기반 메소드고 선택적으로 setValue도 포함할 수 있다.

Object 메소드들은 디폴트 메소드로 제공해선 안 되므로 골격 구현클래스에 구현한다.

추상 클래스의 구현이기 때문에, 선택적으로 implements할 수 있다.

 

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 o) {
        if (o == this)
            return true;
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry<?,?> e = (Map.Entry) o;
        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();
    }
}

 

Map.Entry 인터페이스나 그 하위 인터페이스론 이 골격 구현을 제공할 수 없다. 디폴트 메소드는 Object 메소드를 재정의할 수 없어서다.

 

골격 구현은 상속해서 사용하는걸 가정하므로 아이템 19에서 했던 설계 및 문서화 지침을 모두 따라야 한다.(지금은 예니까 생략)

인터페이스에서 정의한 디폴트 메소드든, 별도 추상 클래스든, 골격 구현은 동작 방식을 잘 정리해서 문서로 남기자.

 

 

 

단순구현(Simple Impementaion)


골격 구현의 작은 변종이며 동작하는 가장 단순한 규현이다. 그대로 써도 되고 필요에 맞게 확장해도 된다.

 

AbstractMap.SimpleEntry를 예로 보자.

 

 

단순 구현도 골격 구현과 같이 상속을 위해 인터페이스를 구현한 건 맞지만 추상 클래스가 아니라는 점이 다르다.

추상 클래스가 아니기 때문에, 필요한 추상 메소드들은 모두 재정의해야 사용가능하다.

추상 클래스는 필요한 메소드를 모두 재정의하지 않고 하위 클래스에서 구현해도 된다.

 

 

정리


다중 구현용 타입으로는 인터페이스가 가장 적합하다.

복잡한 인터페이스라면  골격 구현을 함께 제공하는 방법을 고려하자. 

골격 구현은 가능한 한 인터페이스의 디폴트 메소드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하는 것이 좋다.

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.