산으로 가자▲

20. 추상 클래스보다는 인터페이스를 우선하라. 본문

Effective_Java

20. 추상 클래스보다는 인터페이스를 우선하라.

이성욱입니다 2022. 10. 9. 22:07

자바의 다중 구현 매커니즘 : 인터페이스, 추상 클래스

자바 8부터는 인터페이스도 default 메서드를 제공할 수 있다.

인터페이스 vs 추상 클래스

공통점

  • 선언 내용은 존재하지만 구현 내용은 없다(추상 메서드를 갖는다)
  • 인스턴스로 생성할 수 없다.

목적

  • 인터페이스 : 함수의 껍데기만 존재해서 구현을 강제한다. 구현 객체가 같은 동작을 하도록 보장한다. Has - A
  • 추상 클래스: 추상 클래스를 상속 받아 기능을 이용하고 추가시킨다. Is - A

다중 상속

  • 인터페이스 : 여러개의 인터페이스를 구현할 수 있다.
  • 추상 클래스 : 여러 클래스를 상속 받을 수 없다.

책 본문 내용중 타입이라는 표현은 구현해야하는 메서드를 아울러 표현하는 것으로 이해했다.

추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야한다. 자바는 단일 상속만 지원하므로 추상 클래스 방식은 새로운 타입을 정의하는데 큰 제약을 안게된다. 

-> 기존의 다른 클래스를 상속 받고 있는 상황이라면 추상 클래스를 상속 받는 새로운 타입을 정의하기 까다롭다.

A클래스는 B를 상속받아 구현되어야한다. 
그런데 A클래스에 C의 추상 클래스를 상속받게 하고 싶다면??
public class A extends B {
    ...
    
}

abstract class C {
    ...
}

인터페이스가 선언한 메서드를 모두 정의하고 그 일반 규약을 잘지킨 클래스라면 다른 어떤 클래스를 상속했는 같은 타입으로 취급된다.

-> 어떤걸 상속 받던지 어떤 인터페이스를 구현한 것인지 아닌 건지로 구분하면 됨

A클래스는 B를 상속받아 구현되어야한다. 
그런데 A클래스에 C의 인터페이를 구현 싶다면??
public class A extends B implements AInterface{

}


인터페이스를 여러개 구현하는 것도 문제 없음
public class A extends B implements AInterface, CInterface{

}

기존 클래스의 확장

인터페이스 : 기존 클래스에도 손쉽게 인터페이스를 구현해 넣을 수 있음

ex) Comparable, Iterable, AutoClosable인터페이스가 나왔을 때 수 많은 표준 라이브러리가 구현해서 릴리스됨

추상 클래스 :  추상 클래스는 기존의 클래스에 끼워넣기 쉽지않음

B, C 클래스가 A 추상 클래스를 확장하기 원한다면, A는 계층 구조상 B, C클래스의 공통 조상이 됨. 이런 방식은 클래스의 계층 구조에 커다란 혼란을 일으킨다. 새로 추가된 추상 클래스의 모든 자손이 이를 상속하게 된다. 적절하지 않은 상황에서도 강제된다.

D->B->C->A의 계층 구조를 가진 기존 클래스가 있다고 가정
public class A extends C {
    ...
    
}

public class B extends D {
    ...
    
}
A, B 클래스는 추상 클래스 F를 확장해야하는 상황이라면?
public class A extends C {
    ...
    
}

public class B extends C {
    ...
    
}

abstract class C {
    ...
}

믹스인(Mixin)

믹스인 :클래스가 구현할 수 있는 타입으로, 믹스인을 구현한 클래스에 원래 '주된 타입'외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 줌.

ex) Comparable은 자신을 구현한 클래스의 인스턴스들끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스다.

public class A implements Comparable{

  @Override    //믹스인
  public int compareTo(Object o) {
    return 0;
  }

  public void a(){} //주된 타입

}

추상 클래스는 믹스인을 정의할 수 없다. 기존 클래스에 덧씌울 수 없기 때문이다. 클래스는 두 부모를 가질 수 없고, 클래스 계층구조에는 믹스인을 삽입하기에 합리적인 위치가 없음.

//추상 클래스 C를 상속받아 믹스인을 구현할 수 없다
public class A extends B{

  public void a(){} //주된 타입
  
}

abstract class C{
  abstract int compareTo(Object o); //믹스인
}
//컴포지션을 사용해서 구현할 수 있음
public class A extends B {

  private C c = new C() {
    @Override
    int compareTo(Object o) {
      return 0;
    }
  };
  
  public int compareTo(Object o){ //컴포지션을 사용한 메서드
    return c.compareTo(o);
  }
  
  public void a(){} //주된 타입
}

abstract class C{
  abstract int compareTo(Object o); //믹스인
}

인터페이스는 계층구조가 없는 타입 프레임워크를 만들 수 있다. 타입을 계층적으로 정의하면 수많은 개념을 구조적으로 잘 표현할 수 있지만, 현실에는 계층을 엄격히 구분하기 어려운 개념도 있다.

가수와 작곡가가 있다면 작곡을 하는 가수는 어떤 계층 구조로 작성해야할까?

  • 가수와 작곡가는 계층 구조가 있어보이지 않음.
  • 작곡을 할 수 있는 가수, 없는 가수 클래스를 각각 나눠서 작성하게 되면 클래스가 너무 많아짐. 속성이 n개라면 2^n개 만큼 클래스를 나눠야함 
public abstract class Singer {
   abstract AudioClip sing(Song song);
}

public abstract class Songwriter {
    abstract Song compose(int chartPosition);
}

// 두 개를 extend할 수 없다
public abstract class SingerSongwriter {
    // Singer
    abstract AudioClip sing(Song song);
    // Songwriter
    abstract Song compose(int chartPosition);
    
    abstract AudioClip strum();
    abstract void actSensitive();
}


//이런 구조는 논리적으로 맞지 않다.
public abstract class Singer extends Songwriter{
   abstract AudioClip sing(Song song);
}


public abstract class SingerSongwriter extends Singer{
    // Singer
    abstract AudioClip sing(Song song);
    // Songwriter
    abstract Song compose(int chartPosition);
    
    abstract AudioClip strum();
    abstract void actSensitive();
}

인터페이스로 작성하면 이런 고민이 필요 없다.

public interface Singer  {
    AudioClip sing(Song s);
}

public interface Songwriter  {
    Song compose(int chartPosition);
}

//둘을 상속 받는 새로운 인터페이스도 작성 가능함
public interface SingerSongwriter extends Singer, Songwriter  {
    AudioClip strum();
    void actSensitive();
}

코드 타입을 인터페이스로하면 Singer, Songwriter 모두를 구현해도 문제가 되지않음 나아가 둘을 상속 받아 확장하는 새로운 인터페이스도 설계 가능함.

래퍼 클래서와 인터페이스의 시너지

래퍼 클래스와 인터페이스를 함께 사용하면 인터페이스의 기능을 향상시키는 안전하고 강력한 수단이 됨. 타입을 추상클래스로 정의해두면 그 타입에 기능을 추가하는 방법은 상속뿐이다. 상속해서 만든 클래스는 래퍼 클래스보다 활용도가 떨어지고 깨지기는 더 쉽다.

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 boolean contains(Object o) { return s.contains(o);}
    public boolean isEmpty() { return s.isEmpty();}
    public int size() { return s.size();}
    public Iterator<E> iterator() { return s.iterator(); }
    public boolean add(E e) { return s.add(e); }
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    
    ...

}

인터페이스의 defalut 메서드

인터페이스 메서드 중 구현 방법이 명확한 것이 있다면, default 메서드로 제공 가능함

  • 단, default 메서드를 제공할 때 상속하는 사람들을 위한 설명을 @implSpec 자바독 태그를 붙여 문서화해야함.
  • equals나 hashCode 같은 Object 메서드를 디폴트 메서드로 제공해서는 안됨.
  • 인터페이스는 인스턴스 필드를 가질 수 없고, public이 아닌 정적 맴버도 가질 수 없음(자바 9부터는 private static 메서드도 구현가능) 
public interface CInterface {

  //static 사용
  public static boolean isNegative(int i){
    return i > 0;
  }

  //default 사용
  public default boolean isOne(int i){
    return isNegative(i) && i==1;
  }
}

템플릿 메서드 패턴

인터페이스와 추상 골격 구현(skeletal implementation)클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취할 수 있음. 관례상 인터페이스이름이 Interface라면 그 골격 구현 클래스는 AbstractInterface로 짓는다.

**즉, 추상 클래스 + 인터페이스 조합 **

인터페이스로는 타입을 쉽게 정의하고, 필요하다면 디폴트 메서드 몇 개도 함께 제공함. 그리고 골격 구현 클래스는 나머지 메서드들까지 구현한다.

-> 인터페이스를 구현하는데 필요한 대부분의 일이 완료됨

List 인터페이스를 구현하면서 AbstractList는 추상 클래스로 작성되어있다.

List인터페이스의 get 메서드이다.

AbstractList 추상클래스의 get 메서드이다.

AbstractList에서는 List 인터페이스를 대부분 구현하고 구현하지 않은 메서드는 추상 메서드로 남겨놨음. 의외로 set메서드는 기본 구현체 자체는 만들었는데 구현 내용은 항상 Exception을 던지게 만들었음. 개인적인 생각으로 이런 구조는 

set이 필요없는 상황, 예를 들면 list를 단순히 addAll하는 상황 (addAll은 구현되어있음)에서  클라이언트가 set을 구현해야하는 수고를 없애주는 전략인 것 같음. 

static List<Integer> intArrayAsList(int[] a)  {
    Objects.requireNonNull(a);

    //다이아몬드 연산자를 이렇게 사용하는 건 자바 9부터 가능하다.
    //더 낮은 버전을 사용한다면 <Integer>로 수정하자.
    return new AbstractList<>()  {
      @Override
      public Integer get(int i)  {
        return a[i]; //오토박싱
      }

      @Override
      public Integer set(int i, Integer val)  {
        int oldVal = a[i];
        a[i] = val; //오토언박싱
        return oldVal; //오토박싱
      }

      @Override
      public int size()  {
        return a.length;
      }
    };
  }

골격 구현 작성

1. 인터페이스를 확인해 다른 메서드들의 구현에 사용되는 기반 메서드들을 선정한다(골격 구현에서는 추상메서드가 된다.)

public interface AInterface {

  public boolean equals();

  public int getSize();

  public boolean isEmpty();
  
}

2. 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 모두 디폴트 메서드로 제공한다. 단, equals와 hashCode같은 Object의 메서드는 디폴트 메서드로 제공하면 안 된다는 사실을 유념하자.

public interface AInterface {

  public boolean equals();

  public int getSize(); //기반 메서드

  public default boolean isEmpty(){  //기반 메서드로 구현한 default 메서드
    return getSize() > 0;
  }
}

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

4. 기반 메서드나 디폴트 메서드로 만들지 못한 메서드가 남아 있다면, 이 인터페이스를 구현하는 골격 클래스를 하나만들어 남은 메서드들을 작성해 넣는다. 골격 구현 클래스에 필요하면 public이 아닌 필드와 메서드를 추가해도 된다.

public abstract class AbstractAInterface implements AInterface{

  @Override
  public boolean equals(Object obj) {
    
    ...
    
    return ..;
  }
}

ex) Map.Entry 인터페이스

getKey, getValue는 확실히 기반메서드이고 선택적으로 setValue를 포함할 수 있다. 이 인터페이스는 equals, hashCode의 동작방식도 정의해놨다. Object메서드들은 디폴트 메서드로 제공해서는 안 되므로, 해당 메서드들은 모두 골격 구현 클래스에 구현한다. toString도 기반 메서드를 사용해 구현해놨다.

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 false;
        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인터페이스나 그 하위 인터페이스로는 이 골격 구현을 제공할 수 없음.

-> 디폴트 메서드는 equals, hashCode, toString같은 Object 메서드를 재정의 불가능함.

골격 구현은 기본적으로 상속해서 사용하는 걸 가정하므로 설계 및 문서화 지침을 모두 따라야한다. 인터페이스에 정의한 디폴트 메서드든 별도의 추상 클래스든, 골격 구현은 반드시 그 동작 방식을 잘 정리해 문서로 남겨야한다.

-> 앞선 아이템의 상속을 할 때 일반적으로 지켜야하는 규약을 안고간다.

결론

일반적으로 다중 구현용 타입에는 인터페이스가 가장 적합하다. 복잡한 인터페이스라면 골격 구현을 함께 제공하는 방법을 고려하자. 골격 구현은 '가능한' 인터페이스의 디폴트 메서드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하는 것이 좋다. 인터페이스에 걸려있는 구현상 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하다.