[Effective Java] 4장. 클래스와 인터페이스

kkatal_chae·2022년 8월 28일
0

Effective Java

목록 보기
2/11
post-thumbnail

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

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

⇒ 정보 은닉, 혹은 캡슐화라고 하는 이 개념은 소프트웨어 설계의 근간이 되는 원리다.

정보 은닉의 장점

  • 시스템 개발 속도를 높인다. 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.
  • 시스템 관리 비용을 낮춘다. 각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.
  • 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다. 완성된 시스템에 프로파일링해 최적화할 컴포넌트를 정한 다음, 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문이다.
  • 소프트웨어 재사용성을 높인다. 외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문이다.
  • 큰 시스템을 제작하는 난이도를 낮춰준다. 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다.

기본원칙

모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.

⇒ 소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 한다는 뜻이다.

  • private : 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다.
  • package-private ( default ) : 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다. 접근 제한자를 명시하지 않았을 때 적용되는 패키지 접근 수준이다. ( 단, 인터페이스의 멤버는 기본적으로 public 이 적용된다. )
  • protected : package-private 의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
  • public : 모든 곳에서 접근할 수 있다.

리스코프 치환 원칙

상위 클래스의 메소드를 재정의할 때는 그 접근 수준을 상위 클래스에서보다 좁게 설정할 수 없다.

상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 한다.

클래스가 인터페이스를 구현하는 것은 위의 법칙에 특별한 예로 볼 수 있으며, 이 때 클래스는 인터페이스가 정의한 모든 메소드를 public 으로 선언해야 한다.

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

public 가변 필드를 갖는 클래스는 일반적으로 스레드 안전하지 않다.

💡 public 클래스는 상수용 public static final 필드 외에는 어떠한 public 필드도 가져서는 안된다. public static final 필드가 참조하는 객체가 불변인지 확인하라.

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

public field 는 캡슐화의 장점을 누릴 수 없다.

캡슐화의 장점

  1. 사용자가 불필요한 부분을 접근하지 못하게 하여, 객체의 오용을 방지 할 수 있습니다. (항상 개발자와 사용자가 다를 수 있음을 기억해야 합니다.)
  2. 객체의 내부가 바뀌어도 그 객체의 사용방법이 바뀌지 않습니다. (사용자는 객체 내부가 바뀌어도 같은 결과를 기대 할 수 있습니다.)
  3. 객체 내부에서 사용되는 데이터가 바뀌어도 다른 객체에게 영향을 주지 않습니다. (각 객체들의 독립성이 유지됩니다.)
  4. 객체간의 결합도가 낮아지게 됩니다. (객체간의 결합도를 낮추는 일은 객체 설계의 기본 원칙입니다.)
  5. 큰 시스템 일지라도 컴포넌트 별로 작게 분리하여 개발이 가능하게 됩니다. 이로 인하여 시스템 개발 속도를 높이고 성능을 최적화 하기가 쉽습니다.
💡 public 클래스는 절대 가변 필드를 직접 노출해서는 안 된다. 불편 필드라면 노출해도 덜 위험하지만 완전히 안심할 수는 없다. 하지만 package-private 클래스나 private 중첩 클래스에서는 종종 필드를 노출하는 편이 나을 때도 있다.

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

불변 클래스

그 인스턴스의 내부 값을 수정할 수 없는 클래스. 가변 클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다.

클래스를 불변으로 만들기 위해 따라야하는 규칙

  • 객체의 상태를 변경하는 메소드 ( 변경자 ) 를 제공하지 않는다.
  • 클래스를 확장할 수 없도록 한다.
  • 모든 필드를 final 로 선언한다.
  • 모든 필드를 private 으로 선언한다.
  • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요 없다. 여러 스레드가 동시에 사용해도 절대 훼손되지 않는다. 불변 객체에 대해서는 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니 불변 객체는 안심하고 공유할 수 있다.

⇒ 따라서 불변 클래스라면 한 번 만든 인스턴스를 최대한 재활용하기를 권한다. ( singleton )

객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다

⇒ 불변 객체는 그 자체로 실패 원자성을 제공한다. 상태가 절대 변하지 않으니 잠깐이라도 불일치 상태에 빠질 가능성이 없다.

💡 실패 원자성 : 메소드에서 예외가 발생한 후에도 그 객체는 여전히 ( 메소드 호출 전과 똑같은 ) 유효한 상태여야 한다는 성질

단점 : 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다.

자신을 상속하지 못하게 하는 가장 쉬운 방법 → final 클래스로 선언하는 것

보다 유연한 방법 → 모든 생성자를 private 혹은 package-private 으로 만들고 public static factory 를 제공하는 방법

게터가 있다고 해서 무조건 세터를 만들지는 말자. 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.

불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자.

다른 합당한 이유가 없다면 모든 필드는 private final 이어야 한다. 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

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

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하도록 한다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다.

컴포지션은 HAS-A 관계로 정의될 수 있으며, 기존 클래스가 새로운 클래스의 구성요소가 되는 것이다.

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

⇒ 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.

// 상위 클래스를 상속하고 있는 예시 
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 ); // HashSet.addAll 
	}

	public int getAddCount () {
		return addCount;
	}
}

상속 관계에 놓인 두 클래스에서 메소드를 재정의 할 경우 다양한 문제가 발생한다.

하위 클래스에서 새로운 메소드를 만들었는데 이후에 상위 클래스에 동일한 메소드를 추가했다고 하면 문제가 발생한다.

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 ); // HashSet.addAll 
	}
	
	public int getAddCount () {
		return addCount;
	}
}
// Set 인터페이스에 계측 기능을 입힌 데코레이터 패턴 
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(); }
	... size, iterator, add, remove, addAll, removeAll, toArray etc 

}

구체적으로는 Set 인터페이스를 구현했고, Set 의 인스턴스를 인수로 받는 생성자를 하나 제공한다.

임의의 Set 에 계측 기능을 덧씌워 새로운 Set 으로 만드는 것이 이 클래스의 핵심이다.

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set 에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.

컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부른다. 단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.

래퍼 클래스는 단점이 거의 없다.

한 가지, 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야 한다.

컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문

  • 확장하려는 클래스의 API 에 아무런 결함이 없는가?
  • 결함이 있다면, 이 결함이 여러분 클래스의 API 까지 전파돼도 괜찮은가?
  • 컴포지션으로는 이런 결함을 숨기는 새로운 API 를 설계할 수 있지만, 상속은 상위 클래스의 API 를 ‘ 그 결함까지도’ 그대로 승계한다.
💡 상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

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

상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.

상속용으로 설계한 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야 한다.

상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안된다.

💡 private, final, static 메소드는 재정의가 불가능하니 생성자에서 안심하고 호출해도 된다.

clonereadObject 모두 직간접적으로 재정의 가능 메소드를 호출해서는 안된다.

readObject 의 경우 하위 클래스의 상태가 미처 다 역직렬화되기 전에 재정의한 메소드부터 호출하게 된다.

clone 의 경우 하위 클래스의 clone 메소드가 복제본의 상태를 ( 올바른 상태로 ) 수정하기 전에 재정의한 메소드를 호출한다.

상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이 바람직하다.

상속을 금지하는 방법으로는 두 가지가 있다.

  1. final 클래스로 선언하는 방법
  2. 모든 생성자를 private 이나 package-private 로 선언하고 public static factory 를 만들어주는 방법
💡 클래스 내부에서 스스로를 어떻게 사용하는지 ( 자기사용 패턴 ) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는 한 반드시 지켜야 한다.
그러지 않으면 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오동작하게 만들 수 있다.

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

자바가 제공하는 다중 구현 메커니즘

  • 인터페이스
  • 추상 클래스 ( abstract class )

인터페이스 vs 추상 클래스

추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 한다는 점

인터페이스는 믹스인 정의에 적합하다.

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

추상 클래스로는 믹스인을 정의할 수 없다.

why? 기존 클래스에 덧씌울 수 없기 때문

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

인터페이스에서 메소드를 정의할 수 있는 디폴트 메소드에도 제약이 존재한다.

많은 인터페이스가 equalshashCode 같은 object 의 메소드를 정의하고 있지만, 이들은 디폴트 메소드로 제공해서는 안된다. 또한 인터페이스는 인스턴스 필드를 가질 수 없고 public 이 아닌 static 멤버도 가질 수 없다. ( 단, private static method 는 예외다 )

// 골격 구현을 사용해 완성한 구체 클래스 
static List < Integer > intArrayAsList ( int[] a ) {
	Objects.requireNonNull(a);

	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;
		}
	};
}
// 골격 구현 클래스
public abstract class AbstractMapEntry< K, V > 
		implements Map.Entry < K, V > {

	// 변경 가능한 엔트리는 이 메소드를 반드시 재정의해야 한다. 
	@Override 
	public V setValue( V value ) {
		throw new UnsupportedOperationExceptio();
	}

	// 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();
	}
}
💡 일반적으로 다중 구현용 타입으로는 인터페이스가 가장 적합하다. 복잡한 인터페이스라면 구현하는 수고를 덜어주는 골격 구현을 함께 제공하는 방법을 꼭 고려해보자. 골격 구현은 ‘가능한 한’ 인터페이스의 디폴트 메소드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하는 것이 좋다. ‘가능한 한’이라고 한 이유는, 인터페이스에 걸려 있는 구현상의 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하기 때문이다.

아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라

디폴트 메소드는 ( 컴파일에 성공하더라도 ) 기존 구현체에 런타임 오류를 일으킬 수 있다.

기존 인터페이스에 디폴트 메소드로 새 메소드를 추가하는 일은 꼭 필요한 경우가 아니면 피해야 한다. 반면, 새로운 인터페이스를 만드는 경우라면 표준적인 메소드 구현을 제공하는 데 아주 유용한 수단이며, 그 인터페이스를 더 쉽게 구현해 활용할 수 있게끔 해준다.

아이템 22. 인터페이스는 타입을 정의하는 용도로만 사용하라

인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할을 한다.

⇒ 클래스가 어떤 인터페이스를 구현한다는 것은 자신의 인스턴스로 무엇을 할 수 있는지를 클라이언트에 얘기해주는 것이다.

위의 지침에 맞지 않는 예 - 상수 인터페이스

public interface PhysicalConstants {
	static final double NUMBER = 1;
	static final double NUMBER_2 = 2;
	...
}

상수를 공개할 목적이라면 유틸리티 클래스에 담아 공개하도록 하자

public class PhysicalConstants {
	private PhysicalConstants() { }
	
	public static final double NUMBER = 1;
	public static final double NUMBER_2 = 2;
	...
}

유틸리티 클래스의 상수를 빈번히 사용한다면 정적 임포트하여 클래스 이름은 생략할 수 있다.

import static effctivejava.chapter4.item22...
💡 인터페이스는 타입을 정의하는 용도로만 사용해야 한다. 상수 공개용 수단으로 사용하지 말자.

아이템23. 태그 달린 클래스보다는 클래스 계층 구조를 활용하라

필드들을 final 로 선언하려면 해당 의미에 쓰이지 않는 필드들까지 생성자에서 초기화해야 한다.

또 다른 의미를 추가하려면 코드를 수정해야 한다.

예를 들어, 새로운 의미를 추가할 때마다 모든 switch 문을 찾아 새 의미를 처리하는 코드를 추가해야 하는데, 하나라도 빠뜨리면 역시 런타임에 문제가 불거져 나올 것이다.

💡 태그 달린 클래스를 써야 하는 상황은 거의 없다. 새로운 클래스를 작성하는 데 태그 필드가 등장한다면 태그를 없애고 계층구조로 대체하는 방법을 생각해보자. 기존 클래스가 태그 필드를 사용하고 있다면 계층구조로 리팩토링하는 걸 고민해보자.

아이템 24. 멤버 클래스는 되도록 static 으로 만들라

중첩 클래스란 다른 클래스 안에 정의된 클래스를 말한다.

중첩 클래스는 자신을 감싼 바깥 클래스에서만 쓰여야 하며, 그 외의 쓰임새가 있다면 톱레벨 클래스로 만들어야 한다.

종류

  • static 멤버 클래스

다른 클래스 안에 선언되며, 외부 클래스의 private 멤버에도 접근할 수 있다는 점이 일반 클래스와 다른 특징이다.

  • non-static 멤버 클래스
  • 익명 클래스
  • 지역 클래스

비정적 클래스의 인스턴스는 외부 클래스의 인스턴스와 암묵적으로 연결된다. 그래서 비정적 멤버 클래스의 인스턴스 메소드에서 정규화된 this클래스명.this 형태로 외부 클래스의 이름을 명시하는 용법을 말한다.

아이템 25. 톱레벨 클래스는 한 파일에 하나만 담으라

소스 파일 하나에 톱레벨 클래스를 여러 개 선언하더라도 자바 컴파일러는 불평하지 않는다. 하지만 심각한 위험이 뒤따른다. 이렇게 하면 한 클래스를 여러 가지로 정의할 수 있으며, 그중 어느 것을 사용할지는 어느 소스 파일을 먼저 컴파일하냐에 따라 달라진다.

💡 소스 파일 하나에는 반드시 톱레벨 클래스를 하나만 담자.
이 규칙만 따른다면 컴파일러가 한 클래스에 대한 정의를 여러 개 만들어내는 일은 사라진다. 소스 파일을 어떤 순서로 컴파일하든 바이너리 파일이나 프로그램의 동작이 달라지는 일은 결코 일어나지 않을 것이다.

0개의 댓글