-
[Effective Java] 4장 : 클래스와 인터페이스책/Effective Java 3판 2024. 2. 14. 00:19반응형
아이템 15 - 클래스와 멤버의 접근 권한을 최소화해라
- 정보 은닉은 장점이 많다.
- 접근 제한자 (private, protected, public)를 제대로 활용하는 것이 정보 은닉의 핵심
- 기본 원칙 : 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.
- 톱레벨 클래스와 인터페이스에 부여할 수 있는 접근 수준 - package-private, public
- public : 공개 API가 됨, package-private : 해당 패키지 안에서만 이용
- 패키지 외부에서 쓸 이유가 없다면 package-private으로 → public으로 두면 API가 되므로 하위 호환을 위해 영원히 관리해줘야함
- 한 클래스에서만 사용하는 package-private 톱레벨 클래스나 인터페이스 → 사용하는 클래스 안에 private static으로 중첩시키자
- 바깥 클래스 하나에서만 접근 가능하게 만듦
- 필드(필드, 메서드, 중첩 클래스, 중첩 인터페이스)에 부여할 수 있는 접근 수준 - private, package-private, protected, public
- 공개 API가 아닌 모든 멤버는 private으로 만들자
- 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한해서만 package-private으로 풀어줌
- pubilc 클래스의 멤버를 protected로 바꾸면 이를 상속받은 모든 클래스에서 접근이 가능하다 → 공개 API와 다름이 없게 됨
- 테스트를 위해서 클래스, 인터페이스, 멤버의 접근 범위를 넓히려고 할 때, private 멤버를 package-private까지는 풀어줘도 되지만, 그 이상은 안됨
- 테스트 코드를 테스트 대상과 같은 패키지에 두면 package-private 요소에 접근 가능
- public 클래스의 인스턴스 필드는 되도록 public이 아니어야 함
- 필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 불변식을 보장할 수 없게 된다.
- 또한, 필드가 수정될 때 락 획득 등 다른 작업을 할 수 없으므로 스레드 안전하지 않음
아이템 16 - public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
public class Point { public double x; public double y; }
이런 클래스는 필드에 직접 접근 가능 → 캡슐화가 전혀 안되어있음
API를 수정하지 않고는 내부 표현조차 못 바꿈
불변식 보장도 안됨
외부에서 필드를 수정할 때 부수 작업도 수행 불가능 (락 획득 등)
→ 모든 필드를 private으로 바꾸고 public 접근자 (게터 및 세터)를 추가한다
public class Point { private double x; private double y; public double getX() { return x; } public void setX(double x) { this.x = x; } public double getY() { return y; } public void setY(double y) { this.y = y; } }
public 클래스에서라면 이 방식이 확실히 맞다
그러나 package-private 클래스 혹은 private 중첩 클래스라면 데이터 필드를 노출해도 문제가 없다
→ 패키지 바깥에서는 어차피 접근이 불가능하기 때문이다.
또한 public 클래스의 필드가 불변이라면 단점이 줄기는 하지만, 여전히 좋은 생각은 아니다.
아이템 17 - 변경 가능성을 최소화하라
불변 클래스
인스턴스의 내부 값을 수정할 수 없는 클래스
ex) String, BitInteger 등
클래스를 불변으로 만드는 방법
- 객체의 상태를 변경하는 메서드(세터)를 제공하지 않음
- 클래스를 확장할 수 없도록 → final 클래스 혹은 private 생성자
- 모든 필드를 final로 선언
- 모든 필드를 private로 선언
- 자신 외에는 내부 가변 컴포넌트에 접근할 수 없도록
- 가변 객체를 참조하는 필드가 하나라도 있다면 그 필드의 참조를 절대 얻게 해서는 안됨 → 생성자, 접근자, readObject 메서드 모두에서 방어적 복사 수행
public final class Complex { private final double re; private final double im; public Complex(final double re, final double im) { this.re = re; this.im = im; } public double realPart() { return re; } public double imaginaryPart() { return im; } public Complex plus(final Complex c) { return new Complex(re + c.re, im + c.im); } public Complex minus(final Complex c) { return new Complex(re - c.re, im - c.im); } }
덧셈, 뺄셈 연산 메서드들이 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 반환 → 피연산자에 함수를 적용하지만, 피연산자 자체는 그대로인 패턴이 함수형 프로그래밍
절차적 혹은 명령형 프로그래밍에서는 피연산자가 변하는 경우가 있음
불변 객체의 장점
- 단순하다 - 생성된 시점의 상태를 파괴될 때까지 그대로 간직 but 가변 객체는 변경자 메서드가 일으키는 상태 전이를 문서로 남겨놓아야만 함
- 스레드 안전하여 동기화가 필요 없음 - 따라서 공유가 자유롭고, 한번 만든 인스턴스를 재활용한다면 메모리까지 아낄 수 있다. (캐싱)
class MyInteger { private final int num; // constant private static final MyInteger ONE = new MyInteger(1); private static final MyInteger ZERO = new MyInteger(0); private MyInteger(final int num) { this.num = num; } public static MyInteger of(final int num) { if (num == 0) { return ZERO; } if (num == 1) { return ONE; } return new MyInteger(num); } }
- 불변 객체끼리는 내부 데이터를 공유할 수 있다. - 무슨 소리인지 모르겠다.
- 객체를 만들 때 불변 객체들을 구성요소로 사용하면 이점이 많다. - 불변식을 유지하기 쉽다 → 맵의 키, 집합의 원소로 쓰기 좋다 - 안에 담긴 값이 변하지 않기 때문
- 그 자체로 실패 원자성을 제공 → 상태가 절대 변하지 않으므로
불변의 단점
- 값이 다르면 반드시 독립된 객체로 만들어야한다. - 큰 비용이 들수도 있다
BigInteger moby = BigInteger.valueOf(10000000000L); moby = moby.flipBit(0); // flipBit(0)은 다른 객체를 리턴하므로 반드시 받아줘야함 System.out.println(moby);
불변으로 만드는 또 다른 설계
- 상속을 못하게 막기 - final 클래스 대신 private 생성자와 public 정적 팩터리 제공
- 만약 상속이 가능하다면? MyInteger를 상속받은 하위 객체에서 객체 상태 변경이 가능하게 만들어버릴 수도 있음
- 만약 신로할 수 없는 하위 클래스의 인스턴스라고 확인되면, 가변이라고 가정하고 방어적 복사를 통해 사용해야함
public static MyInteger safeInstance(MyInteger val) { return val.getClass() == MyInteger.class ? val : new MyInteger(val.num); }
정리
- 게터가 있다고 해서 무조건 세터를 만들지는 말자
- 클래스는 꼭 필요한 경우가 아니라면 불변이어야함
- 성능 때문에 어쩔 수 없다면 불변 클래스와 쌍을 이루는 가변 동반 클래스를 제공하자 (String, StringBuilder 처럼)
- 만약 불변으로 만들 수 없다면, 변경할 수 있는 부분을 최소한으로 줄이자
- 다른 합당한 이유가 없다면 모든 필드는 private final이다
- 생성자는 불변식 설정이 모두 완료된 객체를 생성해야함
아이템 18 - 상속보다는 컴포지션을 사용하라
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 같은 패키지라면 안전하다. 그러나 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.
상속의 단점
- 상속은 캡슐화를 깨뜨림 - 상위 클래스가 어떻게 구현되냐에 따라 하위 클래스 동작에 이상이 생길 수 있다.
public class Main { public static void main(String[] args) { InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("틱", "틱틱", "펑")); // expected : 3 System.out.println(s.getAddCount()); } } class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; public InstrumentedHashSet() { } public InstrumentedHashSet(final int initCap, final float loadFactor) { super(initCap, loadFactor); } @Override public boolean add(final 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; } }
3을 기대했지만, 6이 나옴 why? → HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문이다.
- Ins 클래스의 addAll에서 addCount + 3을 한 다음 super.addAll()을 호출하는데, 이 안에서 add 메서드를 호출하고, 이 때 불리는 add 메서드는 ins 클래스에서 재정의한 add가 불리기 때문이다.
- 그렇다면 재정의를 하지 않고 새로운 메서드 추가만 한다면?
- 다음 릴리스 때 상위 클래스에서 메서드를 추가했는데 재수없게 하위 클래스 메서드와 시그니처가 같다면? 컴파일조차 되지 않음
이 모든 걸 해결할 수 있는 것 → 컴포지션
- 기존 클래스의 인스턴스를 private 필드로 가지고 이를 호출함으로써 해결
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); } } class ForwardingSet<E> implements Set<E> { private final Set<E> s; public ForwardingSet(Set<E> s) { this.s = s; } @Override public Spliterator<E> spliterator() { return Set.super.spliterator(); } @Override public int size() { return s.size(); } @Override public boolean isEmpty() { return s.isEmpty(); }
상속은 반드시 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야함 → is-a 관계일 때만 상속해야함
위반한 사례 : Stack은 Vector가 아니므로 확장해서는 안됐다.
아이템 19 - 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라
- 상속을 꼭 사용해야겠다면 API 문서에 내부 구현에 대한 설명이 필요하다
- 또한 클래스 내부에서는 재정의 가능한 메서드를 반드시 사용하지 말아야하며, 문서에도 재정의 가능한 메서드를 꼭 명시해야 한다
- 이 둘 다 캡슐화를 어김 ← 내부 동작 방식을 알아야만 상속받을 때 문제가 없으므로
- 만약 상속이 꼭 필요하지 않다면 final class로 만들거나, private 및 default 생성자를 선언하고 public 정적 팩터리로 자신 리턴하는 방식으로 구현
아이템 20 - 추상 클래스보다는 인터페이스를 우선하라
- 자바 8부터 인터페이스도 디폴트 메서드가 가능 → 추상 클래스, 인터페이스 모두 인스턴스 메서드를 제공할 수 있음
- 인터페이스는 기존 클래스에 손쉽게 끼워넣을 수 있음 → 인터페이스를 구현만 한다면 어떤 계층 구조 사이에도 들어갈 수 있으므로
- 그러나 추상 클래스는 끼워넣기 어려움 → 두 클래스가 같은 추상 클래스를 확장하려고 하려면, 그 추상 클래스가 두 클래스의 공통 조상이어야 함 → 계층 구조에 혼란을 줌
- 템플릿 메서드 패턴
- 인터페이스와 추상 골격 구현 클래스를 함께 제공
- 인터페이스로 골격을 짬 → 추상 클래스로 일부를 구현해놓음 → 이 추상 클래스를 구현함으로써 완성체를 만들 수 있음
- 장점 : 인터페이스의 구현 제약을 추상 클래스를 통해 해결하고, 인터페이스의 장점과 추상 클래스의 장점을 모두 가져감
아이템 21 - 인터페이스는 구현하는 쪽을 생각해 설계하라
- 자바 8부터 인터페이스에 디폴트 메서드를 제공할 수 있지만, 여전히 인터페이스를 설계할 때는 신중을 가해야함
- 디폴트 메서드가 인터페이스를 마음대로 변경가능하게 만들어 주는 것은 아님
- 디폴트 메서드가 모든 케이스에 범용적으로 잘 작동하기는 매우 어려움
- 예) Collection 인터페이스의 removeIf → 범용적으로 구현해놨지만, 아파치의 SynchronizedCollection 에서는 잘 작동하지 않음 → 동기화 부분이 없으므로
아이템 22 - 인터페이스는 타입을 정의하는 용도로만 사용하라
- 인터페이스를 상수를 정의하는 것으로 쓰지 말자
- 인터페이스는 자신을 구현한 클래스의 인스턴스에 대해 해당 동작을 수행할 수 있음을 의미
아이템 23 - 태그 달린 클래스보다는 클래스 계층구조를 활용하라
- 태그 달린 클래스 : enum과 같은 flag를 사용해서 현재 상태를 나타내고, 그 상태에 따라 동작이 달라지는 클래스
- 한마디로 단점 투성이 - switch문 같은 구조가 반드시 나타나야 하고, 코드 수정 시 switch문에서 하나라도 빼먹으면 오류남
- 클래스 계층 구조 → 루트(추상 클래스) → 구현(구현 클래스) 형식으로 만들면 위의 모든 문제 해결
아이템 24 - 멤버 클래스는 되도록 static으로 만들라
- 중첩 클래스의 경우 바깥 클래스의 인스턴스에 접근할 일이 없다면 static을 붙이자
- 비정적 중첩 클래스(멤버 클래스)의 경우 바깥 클래스의 인스턴스로의 숨은 외부 참조를 갖게 됨 → 시간과 공간 소비, 가비지 컬렉터가 바깥 클래스의 인스턴스를 수거하지 못할수도
아이템 25 - 톱레벨 클래스는 한 파일에 하나만 담으라
- 중복되는 경우가 많을 수 있다.
반응형'책 > Effective Java 3판' 카테고리의 다른 글
[Effective Java] 10장 : 예외 (0) 2024.02.14 [Effective Java] 8장 : 메서드 (1) 2024.02.14 [Effective Java] 2장 : 객체 생성과 파괴 (0) 2024.02.13