[Effective Java] 아이템15 ~ 아이템19까지

두별·2023년 4월 23일
0

TIL

목록 보기
41/46

Effective Java 3/E 북스터디 기록

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

캡슐화

잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨, 구현과 API를 깔끔히 분리합니다.
정보 은닉, 혹은 캡슐화라고 하는 이 개념은 소프트웨어의 근간이 되는 원리입니다.

캡슐화 장점

  • 시스템 개발 속도를 높여줍니다.
  • 시스템 관리 비용을 낮춰줍니다.
  • 소프트웨어의 재사용성을 높여줍니다.
  • 외부에 거의 의존하지 않고 독자적으로 동작하는 컴포넌트라면 낯선 환경에서도 유용하게 쓰일 가능성이 높습니다.
  • 큰 시스템을 제작하는 난이도를 낮춰줍니다.

Java의 접근제한자

  • private
    멤버를 선언한 톱레벨 클래스에서만 접근할 수 있습니다.
  • package-private
    멤버가 소속된 패키지 안의 모든 클래스에서 접근 할 수 있습니다.
  • protected
    package-private의 접근 범위를 포함하며 선언한 클래스의 하위 클래스에서도 접근할 수 있습니다.
  • public
    어디에서나 접근 가능합니다.

클래스 레벨

  • 톱레벨 수준이 같은 수준에서의 접근제한자는 public과 package-private만 사용 할 수 있습니다.
    - public으로 선언하는 경우 - 공개 API로 사용하고 하위호환을 평생 신경써야 합니다.
    - package-private로 사용하는 경우 - 해당 패키지 안에서만 사용 가능하여 다음 릴리즈에서도 변경이 가능합니다.

public 클래스의 인스턴스 필드는 되도록 public이 아니어야 합니다.

필드가 가변객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 불변식을 보장할 수 없습니다. public 가변 필드를 갖는 클래스는 일반적으로 thread safe 하지 않습니다.

  • 상수라면 관례대로 public static final 필드로 공개해도 좋습니다.
  • 하지만 클래스에서 public static final 배열 필드를 두면 안됩니다.
    - 배열을 private로 만들고 public 불변 리스트를 추가하거나 public 메서드를 추가하여 줍니다.

결론

  • 프로그램 요소의 접근성은 가능한 최소한으로 설계합니다.
  • public API는 필요한 것만 골라 최소한으로 설계합니다.
  • 상수용 public static final 필드 외에는 어떠한 public 필드도 가져서는 안됩니다. static final field 필드가 참조하는 객체가 불변인지도 확인해야 합니다.

아이템16. Public 클래스에서는 Public 필드가 아닌 접근자 메서드를 사용하라

public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라

public 필드 사용

class Point {
    public double x;
    public double y;
}

public class Execution {
  public static void main(String[] args) {
    Point p = new Point();
    p.x = 123;
    System.out.println(p.x);
  }
}
  • 위와 같은 클래스 Point는 데이터 필드에 직접 접근할 수 있으니 캡슐화의 이점을 제공하지 못합니다.
  • API를 수정하지 않고는 내부 표현을 바꿀 수 없습니다.
  • 불변식을 보장할 수 없습니다.

접근자와 변경자 설정을 통해 데이터를 캡슐화

class Point2 {
  private double x;
  private double y;

  public Point2(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() {
    return x;
  }

  public double getY() {
    return y;
  }

  public void setX(double x) {
    this.x = x;
  }
  public void setY(double y) {
    this.y = y;
  }
}

public class Execution {
  public static void main(String[] args) {
    Point2 p2 = new Point2(12, 34);
    p2.setX(123);
    System.out.println(p2.getX());
  }
}
  • private field를 사용하여 직접적인 접근을 막습니다.
  • 접근자(getter)와 수정자(setter)를 통해 내부 표현 방식의 유연성을 얻습니다.
  • package-private(default class) 혹은 private 중첩 클래스라면 데이터 필드를 노출한다 해도 문제가 없습니다.

결론

  • public 클래스는 절대 가변 필드를 직접 노출해서는 안됩니다.
  • 불변 필드라면 노출해도 덜 위험하지만 완전히 안심할 수는 없습니다. (item 15의 배열의 경우 안심할 수 없음)
  • package-private 클래스나 중첩 클래스에서는 종종 필드를 노출하는 편이 나을때도 있습니다.

아이템17. 변경 가능성을 최소화하라

불변 클래스(Immutable Class)란 ?

  • 인스턴스의 내부 값을 수정할 수 없는 클래스를 말합니다.
  • 인스턴스의 저장된 정보가 객체가 파괴되기 전까지 바뀌지 않습니다
  • 대표적으로 String, Boolean, Integer, Float, Long 등등이 있습니다
  • Immutable Class들은 heap영역에서 변경불가능 한 것이지 재할당을 못하는 것은 아닙니다

불변 클래스 사용 이유는 무엇일까요?

  • 설계, 구현, 사용이 쉽습니다
  • thread-safe하고 에러를 만들 가능성이 적습니다

클래스를 불변으로 만들시 지켜야 할 규칙

객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.

setter나 필드의 정보를 변경하는 메서드를 제공하지 않는다

클래스를 확장할 수 없도록 한다.

객체의 상태를 변하게 만드는 사태를 막아준다. 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이다.

모든 필드를 final로 선언한다.

  • 필드의 수정을 막겠다는 설계자의 의도를 드러내는 방법이다.
  • 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하여 준다.

모든 필드를 private으로 선언한다.

필드가 참조하는 가변 객체를 클라이언트에서 직접 접근하여 수정하는 일을 막아 준다.

자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

  • 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 인스턴스 내에 가변 객체의 참조를 얻을 수 없게 해야한다.
  • 생성자, 접근자(getter), readObject 메서드 모두에서 방어적 복사를 수행한다.

불변 복소수 클래스 Complex

public final class Complex {

    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public double realPart() {
        return re;
    }

    public double imaginaryPart() {
        return im;
    }

    public Complex plus(Complex c) {
        return new Complex(re + c.re,im + c.im);
    }

    public Complex minus(Complex c) {
        return new Complex(re - c.re,im - c.im);
    }

    public Complex times(Complex c) {
        return new Complex(re * c.re - im * c.im,re * c.im + im + c.re);
    }

    public Complex dividedBy(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp,
                (im * c.re - re + c.im) / tmp);
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof Complex)) {
            return false;
        }

        Complex c = (Complex) o;
        return Double.compare(c.re, re) == 0
                && Double.compare(c.im, im) == 0;
    }

    @Override
    public int hashCode() {
        return 31 * Double.hashCode(re) + Double.hashCode(im);
    }

    @Override
    public String toString() {
        return "(" + re + " + " + im + "i)";
    }

}
  • 실수부와 허수부 값을 반환하는 접근자(realPart, imaginaryPart)와 사칙연산 메서드(plus, minusm times, dividedBy)를 정의하였다.
  • 이 사칙연산 매서드들은 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환한다.
  • 이처럼 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라 한다.
  • 이처럼 함수형 프로그래밍 기법을 적용하면 코드의 불변이 영역이 되는 비율이 높아져 안전합니다.불변 객체의 장점
    불변 객체는 근본적으로 스레드 안전하여 따로 동기화 할 필요가 없다
  • 불변 객체는 안심하고 공유 할 수 있다
  • 불변 객체는 방어적 복사본이 필요없다
  • 불변 객체는 자유롭게 공유할 수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할 수 있다.
  • 불변 객체를 key로 하면 이점이 많다.
    • Map의 key
    • Set의 원소
  • 불변 객체는 그 자체로 실패 원자성을 제공한다

결론

  • 모든 클래스를 불면으로 만들수는 없다.
  • 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄여야 한다.
    다른 합당한 이유가 없다면 모든 필드는 private final 이어야 한다.
    생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

아이템18. 상속보다는 컴포지션을 사용하라

상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다.
잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

  • 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
  • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한줄 건드리지 않은 하위 클래스가 오작동할 수 있다.
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet(){}

    public InstrumentedHashSet(int initCap, float loadFactor){
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

public class Item18 {
    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));

        System.out.println(s.getAddCount());
    }
}
  • getAddCount()의 결과가 3을 반환하리라 생각하겠지만 6을 반환한다. HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다.
  • 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있다.
    - 하지만 이처럼 자신의 다른 부분을 사용하는 '자기사용' 여부는 해당 클래스의 내부 구현방식에 해당하며, 자바 플랫폼 전반적인 정책인지, 그 다음 릴리즈에도 유지될지 알 수 없다.
  • 그렇다면 재정의 대신 새로운 메서드를 추가하면 괜찮을까?
    - 괜찮은 방법이라 생각할수도 있지만, 위험이 전혀 없는 것은 아니다.
    - 다음 릴리스에 상위 클래스에 새 메서드가 추가 됐는데, 운 없게도 하필 추가된 메서드와 시그니처가 같고 반환 타입이 다를 수도 있다. 컴파일 문제가 바로 발생한다.

상속대신 컴포지션을 이용

  • 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다.

  • 컴포지션을 통해 새 클래스의 인스턴스 메서드들은 기존 클래스에 대응하는 메서드를 호출해 그 결과를 반환하게 한다.

  • 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.

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 remove(Object o) {
    return s.remove(o);
  }

  public boolean containsAll(Collection<?> c) {
    return s.containsAll(c);
  }

  public boolean addAll(Collection<? extends E> c) {
    return s.addAll(c);
  }

  public boolean removeAll(Collection<?> c) {
    return s.removeAll(c);
  }

  public boolean retainAll(Collection<?> c) {
    return s.retainAll(c);
  }

  public Object[] toArray() {
    return s.toArray();
  }

  public <T> T[] toArray(T[] a) {
    return s.toArray(a);
  }

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

  @Override
  public int hashCode() {
    return s.hashCode();
  }

  @Override
  public String toString() {
    return s.toString();
  }
}

public class InstrumentedSet<E> extends ForwardingSet<E> {
  private int addCount = 0;

  public InstrumentedSet(Set<E> s) {
    super(s);
  }

  @Override
  public boolean add(E e) {
    addCount++;
    return super.add(e);
  }

  @Override
  public boolean addAll(Collection<? extends E> c) {
    addCount += c.size();
    return super.addAll(c);
  }

  public int getAddCount() {
    return addCount;
  }
}

public class Item18 {
  public static void main(String[] args) {
    InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
    s.addAll(List.of("틱", "탁탁", "펑"));
    System.out.println(s.getAddCount());

    InstrumentedSet<String> s2 = new InstrumentedSet<>(new HashSet<String>());
    s2.addAll(List.of("틱", "탁탁", "펑"));
    System.out.println(s2.getAddCount());
  }
}

결론

  • 상속은 강렬하지만 캡술화를 해친다는 문제가 있다.
    상속은 상위 클래스와 하위 클래스가 is-a 관계일 때만 써야 한다.
  • 상위 클래스와 하위 클래스의 패키지가 다를 경우에는 is-a 관계라도 문제가 발생할 수 있다.
  • 상속의 취약점을 피하려면 상속 대신 컴포지션 전달을 사용하자.

아이템19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.

상속용 클래스가 지켜야 할 것들

  • 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
    • 어떤 순서로 호출하는지, 호출 결과가 이어지는 처리에 어떤 영향을 주는지도 담아야 한다.
    • 재정의 가능 메서드란 public, protected 중 final이 아닌 모든 메서드를 말한다.
    • 재정의 가능한 메서드를 호출할 수 있는 모든 상황을 문서로 남기는 것이 좋다.
      • 백그라운드 스레드나 정적 초기화 과정에서 호출될 수도 있으므로 유의하자.

Implentation Requirements와 @implSpec 태그 - remove() 의 예

API 문서 메서드 설명 끝에서 발견할 수 있는 문구 중 Implementation Requirements가 있다. 이 절은 메서드 주석에 @implSpec 태그를 붙이면 자바독 도구가 생성해준다.

/**
* {@inheritDoc}
*
* @implSpec
* This implementation iterates over the collection looking for the
* specified element.  If it finds the element, it removes the element
* from the collection using the iterator's remove method.
*
* <p>Note that this implementation throws an
* {@code UnsupportedOperationException} if the iterator returned by this
* collection's iterator method does not implement the {@code remove}
* method and this collection contains the specified object.
*
* @throws UnsupportedOperationException {@inheritDoc}
* @throws ClassCastException            {@inheritDoc}
* @throws NullPointerException          {@inheritDoc}
*/
public boolean remove(Object o) {
  Iterator<E> it = iterator();
  if (o==null) {
      while (it.hasNext()) {
          if (it.next()==null) {
              it.remove();
              return true;
          }
      }
  } else {
      while (it.hasNext()) {
          if (o.equals(it.next())) {
              it.remove();
              return true;
          }
      }
  }
  return false;
}
  • 주석 위쪽 @implSpec 태그를 볼 수 있다.
    • 엘리먼트를 찾기 위해 컬렉션을 순회하고, iterator의 remove()를 통해 원소를 제거한다고 적혀있다.
    • iterator에 remove()가 구현되어 있지 않다면, UnsupportedOperationException을 던진다고 상세히 설명하고 있다.
  • @implSpec은 이 클래스를 상속하여 메서드를 재정의했을 때 나타날 효과를 상세히 설명하고 있다.
    • 이 주의점을 통해 우리는 어떤 메서드를 어떤 방식으로 상속해야 할지 알 수 있다.
    • 상속용 클래스가 지켜야 할 좋은 문서화의 예이다.

훅(hook) 메서드 공개하기 - removeRange() 의 예

/**
 * Removes from this list all of the elements whose index is between
 * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
 * Shifts any succeeding elements to the left (reduces their index).
 * This call shortens the list by {@code (toIndex - fromIndex)} elements.
 * (If {@code toIndex==fromIndex}, this operation has no effect.)
 *
 * <p>This method is called by the {@code clear} operation on this list
 * and its subLists.  Overriding this method to take advantage of
 * the internals of the list implementation can <i>substantially</i>
 * improve the performance of the {@code clear} operation on this list
 * and its subLists.
 *
 * @implSpec
 * This implementation gets a list iterator positioned before
 * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
 * followed by {@code ListIterator.remove} until the entire range has
 * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
 * time, this implementation requires quadratic time.</b>
 *
 * @param fromIndex index of first element to be removed
 * @param toIndex index after last element to be removed
 */
protected void removeRange(int fromIndex, int toIndex) {
    ListIterator<E> it = listIterator(fromIndex);
    for (int i=0, n=toIndex-fromIndex; i<n; i++) {
        it.next();
        it.remove();
    }
}
  • 주석에서 이 메서드가 clear()에 의해 호출됨을 알리고 있다.
  • clear()를 고성능으로 만들기 쉽게 하기 위해 이 메서드를 외부로 공개하고 있다.
  • 이렇게 특정한 이유로 protected 접근 제어자로 메서드를 노출해야 할 필요가 있는 경우도 있다.

상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 유일하다. 직접 시험하며 어떤 메서드를 공개할지 선택하면 된다. 만일 하위 클래스를 여러개 만드는 동안 한번도 쓰이지 않는 protected 멤버가 존재한다면, private이었어야 할 가능성이 크다. 널리 쓰일 클래스를 상속용으로 설계한다면 설계의 결정요소와 문서화의 책임이 더욱 크다. 상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증하자.

재정의 가능 메서드를 생성자에서 사용하면 안된다.

  • 상속용 클래스의 생성자는 직접적이든, 간접적이든, 재정의 가능 메서드를 호출하면 안된다.
  • 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되기 때문이다.
public class Super {
  public Super() {
    overrideMe();
  }

  public void overrideMe() {
  }
}
public class Item19Test {
    static class Super {
        public Super() {
            overrideMe();
        }

        public void overrideMe() {
            System.out.println("super's override me");
        }
    }

    static class Sub extends Super {
        private final Instant instant;

        Sub() {
            // 상속받은 클래스는 자동으로 부모 클래스의 생성자를 호출한다.
            instant = Instant.now();
        }

        @Override
        public void overrideMe() {
            System.out.println("instant = " + instant);
        }
    }

    @Test
    public void constructorTest() {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

출력 결과

instant = null
instant = 2022-01-01T11:06:14.830557Z
  • 상위 클래스는 하위 클래스가 인스턴스 필드를 초기화하기도 전에 overrideMe()를 호출한다.
  • final 필드의 상태가 두가지다. (정상이 아니다.)
  • print는 null을 받아들이기 때문에 정상적으로 실행됐지만, 다른 경우 null 값을 사용했다면, NullPointerException의 위험이 존재한다.

Cloneable, Serializable

  • 직렬화, 객체 복사에 사용되는 clone(), readObject()와 같은 경우도 생성자와 비슷한 효과를 가지고 있으므로 직접적이든 간접적이든 재정의 가능한 메서드를 호출해선 안 된다.
    • readObject()는 역직렬화가 끝나기 전에 재정의한 메서드부터 호출하게 된다.
    • clone()는 하위 클래스의 clone() 메서드가 복제본의 상태를 올바른 상태로 수정하기 전에 재정의한 메서드를 호출한다.
      • clone()이 잘못되면 원본 객체에도 피해를 줄 수 있다.

상속용 클래스와 그 제약

  • 클래스를 상속용으로 설계하려면 엄청난 노력이 들고 그 클래스 안에 제약도 상당하다.
  • 상속용으로 설계하지 않은 클래스는 상속을 금지하는 편이 버그를 줄일 수 있다.
    • 클래스를 final로 만들어 상속을 금지한다.
    • 모든 생성자를 private 혹은 package-private으로 선언하고 public 정적 팩터리를 만든다.
  • 혹여나 일반 클래스에서 상속을 허용하고 싶다면, 재정의 가능 메서드는 절대 사용하지 않도록 문서에 표기하자.

결론

  • 상속용 메서드를 만들 때는 클래스 내부에서 스스로를 어떻게 사용하는지 문서로 남기자.
  • 문서화한 것은 그 클래스가 쓰이는 한 반드시 지키자.
    • 그렇지 않을 경우 하위 클래스의 오동작을 만들 수 있다.
  • 클래스를 확장해야 할 명확한 이유가 떠오르지 않으면 상속을 금지하자.
    • 클래스를 final로 만들거나 생성자를 모두 외부에서 접근 불가능하게 바꾸면 된다.

0개의 댓글