Language/Java

[이펙티브자바] 아이템 13, 14, 15 요약 정리

anxi 2024. 6. 2. 21:36

아이템 13. clone 재정의는 주의해서 진행하라

Cloneable : 복제해도 되는 클래스임을 명시하는 용도의 인터페이스

-> 의도한 목적을 제대로 이루지 못함.

 

가장 큰 문제 : clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, protected 접근제어자

그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.

 

Cloneable Interface

Cloneable이 하는 일

  • Cloneable 인터페이스를 구현하지 않고 Object's clone 메서드 사용시 'CloneNotSupportedException' 발생
  • Object의 protected 메서드인 clone의 동작 방식을 결정
    • Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체 반환

Clone 메서드가 제대로 동작하려면

상위 클래스가 제대로 동작하는 clone 메서드를 가지고, Cloneable interface를 구현(implements) 해줄 때 제대로 동작한다.

 

가변 상태를 참조하지 않는 clone 메서드

super.clone을 호출한다. => 얻은 객체는 원본의 완벽한 복제본이다. (클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다.)

public static class PhoneNumber implements Cloneable {
    @Override
    public PhoneNumber clone() {
      try {
        return (PhoneNumber) super.clone();
      } catch (CloneNotSupportedException e) {
        throw new AssertionError();
      }
    }
  }

 

  • Object의 clone 메서드는 Object를 반환하지만 PhoneNumber의 clone 메서드는 PhoneNumber을 반환하게 한다.
    • 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다.
  • Object에서는 protected로 선언되어있지만, PhoneNumber 클래스에서는 clone()를 public 제한자로 변경할 수 있다.

Object clone 메서드

 

가변 상태를 참조하는 clone 메서드

만약 PhoneNumber 클래스에 short[] 배열이 존재한다고 가정해보자.

위의 가변 객체를 참조하는 순간, clone 메서드로 복제시 short[] 배열을 원본 인스턴스와 복제 인스턴스에서 공유해서 같은 객체를 참조하는 문제가 발생한다.

 

해결하기 위해선 short[] 배열의 clone을 재귀적으로 호출하는 것이다.

public static class PhoneNumber implements Cloneable {
    
    short[] elements;
    
    @Override
    public PhoneNumber clone() {
      try {
        PhoneNumber result = (PhoneNumber) super.clone();
        result.elements = elements.clone();
        return result;
      } catch (CloneNotSupportedException e) {
        throw new AssertionError();
      }
    }
  }

 

elements(상위 객체)의 clone 메서드를 호출함으로써 참조 필드에 대해 복사를 진행한다.

 

요약 : Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다. 이때 접근 제한자는 public, 반환 타입은 클래스 자신으로 변경한다.

하지만 Cloneable을 구현한 클래스가 아니라면 다른 방법으로 clone보다 더 나은 객체 복사 방식을 제공할 수 있다.

 

복사 생성자 / 복사 팩터리

  • 복사 생성자 : 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자
  • 복사 팩터리 : 복사 생성자를 모방한 정적 팩터리
 public static class PhoneNumber {

    int[] elements = new int[]{1, 2, 3, 4, 5};

    public PhoneNumber(){}

    // 복사 생성자
    public PhoneNumber(PhoneNumber phoneNumber) {
      // phoneNumber 인스턴스의 객체를 복사하는 과정을 작성한다.
      for (int i = 0; i < phoneNumber.elements.length; i++) {
        elements[i] = phoneNumber.elements[i];
      }
    }

    // 복사 팩터리
    public static PhoneNumber newInstance(PhoneNumber phoneNumber) {
      PhoneNumber instance = new PhoneNumber();
      for (int i = 0; i < phoneNumber.elements.length; i++) {
        instance.elements[i] = phoneNumber.elements[i];
      }
      return instance;
    }
  }

 

  • 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 '인터페이스' 타입의 인스턴스를 인수로 받을 수 있다.
    HashSet<Integer> set = new HashSet<>();
    TreeSet<Integer> treeSet = new TreeSet<>(set);

    List<Integer> list = new ArrayList<>();
    List<Integer> copy = new ArrayList<>(list);

 

-> 이런 식으로 Collection이나 Map 타입의 변환에 사용된다.

 

결론 : 복제 기능은 복사 생성자와 팩터리를 이용하자! 단, 배열만은 clone 메서드 방식이 가장 깔끔하다.


아이템 14. Comparable을 구현할지 고려하라

Comparable 인터페이스의 유일무이한 메서드인 compareTo는 

  • 단순 동치성 비교에 더해 순서까지 비교할 수 있다.
  • 제네릭하다.

위 두가지로 성격을 나타낼 수 있다.

 

알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자!

 

compareTo 메서드의 일반 규약

  • 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
  • sgn : 부호 함수 (표현식)
  • Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x)) 어야 한다.
  • Comparable을 구현한 클래스는 추이성을 보장해야 한다.
    즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0) 이면 x.compareTo(z) > 0이다.
  • x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) 다.
  • 필수 x : (x.compareTo(y) == 0) == (x.equals(y)) 여야 한다.

compareTo 메서드 작성 과정

  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일 타임에 정해진다. 
  • compareTo 메서드는 각 필드가 동치인지를 비교하는 것이 아닌 그 순서를 비교한다.
  • Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교한다면 비교자(Comparator)를 사용한다.
    • 비교자는 직접 만들거나 자바가 제공하는 것 중에 골라 쓰면 된다.
  • compareTo 메서드에서 정수 기본 타입 필드를 비교할 때 기존엔 관계연산자 <, >를, 실수 기본 타입 필드를 비교할 때는 Double.compare와 Float.compare를 사용했지만
    자바 7부터는 박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용한다.
  • 가장 핵심적인 필드부터 비교해 나가는 것이 좋다. 
public static class PhoneNumber implements Comparable<PhoneNumber> {
    short areaCode, prefix, lineNum;

    @Override
    public int compareTo(PhoneNumber o) {
      int result = Short.compare(areaCode, o.areaCode); // 가장 중요한 필드
      if (result == 0) {
        result = Short.compare(prefix, o.prefix); // 두 번째로 중요한 필드
        if (result == 0) {
          result = Short.compare(lineNum, o.lineNum); // 세 번째로 중요한 필드
        }
      }
      return result;
    }
  }

 

  • 아래는 자바8부터 사용 가능한 비교자(Comparator)를 사용한 방식이다.
public static class PhoneNumber {
    short areaCode, prefix, lineNum;
    
    private static final Comparator<PhoneNumber> COMPARATOR = 
        Comparator.comparingInt((PhoneNumber pn) -> pn.areaCode)
            .thenComparingInt(pn -> pn.prefix)
            .thenComparingInt(pn -> pn.lineNum);
    
    public int compareTo(PhoneNumber pn) {
      return COMPARATOR.compare(this, pn);
    }
  }

 

결론

  • 순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하자.
  • 필드의 값을 비교할 때 관계 연산자를 사용하지 말고 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

아이템 15. 클래스와 멤버의 접근 권한을 최소화하라

잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨, 구현과 API를 깜끔히 분리한다.

오직 API를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 신경쓰지 않는다.

위 원리는 정보 은닉, 혹은 캡슐화를 이용해서 숨길 수 있다.

 

정보은닉 장점(4)

  1. 시스템 개발 속도를 높인다. (병렬 개발 가능)
  2. 시스템 관리 비용 낮춘다. (각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고 다른 컴포넌트로 교체하는 부담도 적기 때문)
  3. 성능 최적화에 도움이 된다. (성능을 높여주진 않지만 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문)
  4. 소프트웨어 재사용성 높인다. 
  5. 큰 시스템을 제작하는 난이도를 낮춰준다. (시스템 전체가 미완이더라도 개별 컴포넌트의 동작을 검증할 수 있기 때문)

정보 은닉의 다양한 장치

  • 접근 제어 메커니즘 : 클래스, 인터페이스, 멤버의 접근성을 명시
  • 이 접근 제한자(private, protected, public)를 제대로 활용하는 것이 정보 은닉의 핵심

정보 은닉의 기본 원칙

모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.
(= 소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 한다.)

 

  • 패키지 외부에서 쓸 이유가 없다면 package-private(default) 으로 선언하자.
    • 클라이언트에 아무 피해 없이 다음 릴리즈에 수정, 교체, 제거할 수 있다.
  • 한 클래스에서만 사용하는 package-private(default) 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static으로 중첩시켜 사용하자.
  • public일 필요가 없는 클래스의 접근 수준을 package-private 톱레벨 클래스로 좁히자.

접근 수준 4가지

  • private : 멤버를 선언한 톱레벨 클래스(가장 바깥 클래스)에서만 접근할 수 있다.
  • package-private(default) : 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다.
  • protected : package-private 의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스(상속)에서도 접근할 수 있다.
  • public : 모든 곳에서 접근할 수 있다.

멤버 접근 권한 주의사항 (고려할 점)

  • 클래스의 공개 API를 세심히 설계한 후, 그 외의 모든 멤버는 private으로 만들자.
  • 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private(default)로 풀어주자.
  • 권한을 풀어 주는 일을 자주 하게 된다면 컴포넌트를 더 분해해야 하는지 고민해보자.
  • private, package-private 멤버는 모두 해당 클래스의 구현에 해당하므로 보통은 공개 API에 영향을 주지 않는다. 단, Serialziable을 구현한 클래스에서는 그 필드들이 의도치 않게 공개 API가 될 수 있다.
  • protected 멤버의 수는 적을수록 좋다. (접근 대상 범위가 넓어지기 때문)
  • public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다. (예외. 상수일 경우 public static final 필드로 공개)
    • public static final 필드는 불변이어야 한다.
    • 상수의 이름은 대문자 알파벳으로 쓰며 단어 사이에 밑줄(_)을 넣는다.
    • 상수는 기본 타입 값이나 불변 객체를 참조해야 한다. 
  • public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안된다.
public static final Thing[] VALUES = { ... };

 

참조 타입(배열)에 경우 접근자를 제공하면 final로 선언해도 내용을 수정할 수 있게 된다.

 

해결책

1. private로 만들고 public 불변 리스트를 추가한다.

private static final Thing[] PRIVATE_VALUE = { ... };
public static final List<Thing> VALUES = 
	Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES);

 

2. 배열을 private로 만들고 그 복사본을 반환하는 public 메서드를 추가하는 방법(방어적 복사)

private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
	return PRIVATE_VALUES.clone();
}