반응형

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

 

자바에서는 다중 구현 메카니즘으로 추상 클래스와 인터페이스를 제공하고 있다.

자바8 부터 인터페이스도 default 메서드와 static 메서드를 제공할 수 있게 되어 이제는 두 메카니즘 모두 인스턴스 메서드를 구현 형태로 제공할 수 있다. 자바8 의 인터페이스 기초 영상은 아래를 참고하자.

 

 

사실 큰 차이는 없어지고, 추상클래스의 단점만 부각되는 꼴이 되었다.

추상 클래스를 정의한 타입을 구현한 클래스는 반드시 추상 클래스의 하위 클래스가 되어야 하는데 자바가 단일 상속만 지원하다 보니, 추상 클래스 방식은 새로운 타입을 정의하는데 커다란 제약이 있다.

반면, 인터페이스는 선언된 메서드들을 모두 구현하고 일반 규약을 잘 지킨다면 다른 어떤 클래스를 상속해도 같은 타입으로 취급된다. 그래서 기존 클래스에 새로운 인터페이스를 구현할 수 있다는 장점이 있다. 인터페이스의 장점에 대해 더 자세히 알아보자.

 

인터페이스의 장점

1 . 인터페이스는 믹스인 정의에 알맞다.

mixin 은 클래스 구현 타입으로 '주된 타입' 외에 선택적 기능을 혼합하여 제공함을 뜻한다. 예를 들어 Comparable 은 자신을 구현한 클래스의 인스턴스끼리는 순서를 정할 수 있다고 선언하는 믹스인 인터페이스이다. 추상 클래스는 기존 클래스를 덮어 씌워 계층 구조를 이루어야 하기 때문에 부가 기능을 혼합해서 제공하기가 힘들다. (상속은 완벽히 IS-A 관계를 가져야 함)

 

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

타입을 계층적으로 정의하는 상속 구조가 개념들을 구조적으로 표현할 수 있지만, 상속은 캡슐화를 해칠 수 있다.

반면에 인터페이스는 아래와 같이 부가 기능들을 섞어 새로운 타입으로 만들 수 있다. 상속처럼 다른 클래스로 구현할 때 어떠한 제약이 없다.

 

interface Singer {
    AudioClip sing(Song s);
}
interface SongWriter {
    Song compose(int chartPosition);
}
interface SingerSongwriter extends Singer, SongWriter {
    AudioClip strum();
    void actSensitive();
}

 

같은 구조를 추상 클래스로 만들었다면 가능한 조합 전부를 각각의 클래스로 정의한 고도비만 계층구조가 만들어진다.

완벽한 IS-A 관계에 있어야 하므로, 속성이 N 개라면 지원해야 할 조합의 갯수는 2^N 개가 된다.

 

인터페이스의 디폴트 메서드

인터페이스 규약이 바뀌어 새로운 메서드를 추가 선언해야 된다고 한다면, 그 인터페이스를 구현한 모든 클래스에서 그 해당 메서드를 추가 구현해야 한다. 이런 문제점 때문에 자바 8 부터 디폴트 메서드를 제공하고 있다. 인터페이스 메서드 중 구현 방법이 명백한 것이 있다면 그 구현을 디폴트 메서드로 제공해도 된다. 참고로, 많은 인터페이스들이 equals 와 hashCode 같은 Object 의 메서드를 정의하는데 이들을 디폴트 메서드로 제공하면 안 된다.

 

인터페이스의 단점

인터페이스는 인스턴스 필드를 가질 수 없고 public 이 아닌 정적 멤버도 가질 수 없다.

 

인터페이스 + 추상 골격 구현 = 템플릿 메서드 패턴

인터페이스의 장점과 추상 클래스의 장점을 모두 취한 패턴이 템플릿 메서드 패턴이다. 인터페이스로 타입을 정의하고 골격 구현 클래스에서 나머지 메서드들까지 구현하면서 골격 구현을 확장하는 것만으로 인터페이스를 구현하게 되버리고 쉬어진다.

 

 

 

"다중 구현 타입으로는 인터페이스가 가장 적합하다.

복잡한 인터페이스라면, 구현한는 수고를 덜어주는 골격 구현 클래스를 함께 고려해보자."

반응형
반응형

ITEM 19 "상속을 고려해 설계하고 문서화하라"

 

Item 18 에서 상속을 고려해두지 않고 설계한 클래스를 상속했을 때 문제점들을 살펴보았다.

이번 장에서 상속용 클래스를 설계하는 방법에 대해 자세히 살펴보자.

 

첫 번째, 메서드를 재정의하면 어떤 일이 일어나는지 정확히 정리하여 문서로 남겨야 한다.

Item 18 에서 본 것처럼, 클래스 API 가 자신의 다른 메서드를 호출할 수 있다. 더 나아가 어떤 순서로 호출되는지, 각각의 호출 결과가 어떤 side-effect 를 일으키는지 호출했을 때 모든 상황을 문서로 남겨야 한다. 백그라운드 스레드나 정적 초기화 과정에도 호출되어 다른 영향을 끼칠 수 있으니 모두 작성하자.

 

API 문서의 메서드 설명 끝에 "Implementation Requirements" 로 시작되는 절을 볼 수 있는데, 이 부분이 바로 메서드의 내부 동작 방식을 설명하는 곳이다. @ImplSpec 태그를 메서드 주석에 붙여두면 자바독 도구가 자동으로 생성해준다.

 

java.util.AbstractCollection remove 메서드

 

java.util.AbstractCollection remove 메서드의 주석부분을 살펴보면 Iterator 메서드를 재정의하면 remove 메서드 동작에 영향을 미칠 수 있다는 것을 확실히 알 수 있다. "좋은 API 문서는 '어떻게' 가 아닌 '무엇을' 하는지 설명해야 한다" 라는 격언과 반대로 상속은 캡슐화를 해치기 때문에 이렇게 자세히 작성해두어야 한다.

 

두 번째, 클래스 내부 동작 과정 중간에 끼어들 수 있는 훅을 잘 선별하여 protected 메서드 형태로 공개하여야 한다.

하위 클래스에서 사용할 수 있도록 상위 클래스에서 미리 기능들을 제공할 필요가 있다.

 

java.util.AbstractList removeRange 메서드

 

java.util.AbstractList removeRange 메서드는 하위 클래스에서 부분 리스트의 clear 메서드를 고성능으로 제공하기 위해 만들어졌다고 한다. 하위 클래스에서 잘 사용할 수 있도록 메서드들을 미리 구현하여 protected 지시어로 제공하는데 어떤 메서드를 노출시킬지 결정하기가 쉽지 않다고 한다. 상속용 클래스의 하위 클래스들을 직접 만들어 보고 여러 개 만들었는데도 전혀 쓰이지 않는다면 private 이었어야 할 가능성이 크다. 이전 아이템에서 소개했듯이 상속용 클래스를 만들고 릴리즈하면 해당 클래스를 기반으로 하위 클래스들이 여러 개 생기므로, 반드시 하위 클래스를 만들어 검증해야 한다.

 

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

상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출될 수 있다. 하위 클래스 생성과 동시에 로직이 잘못 실행될 수 있으므로 주의가 필요하다.

 

// 절대 해서는 안 된다.
public class Super {
    public Super() { overrideMe(); }
    public void overrideMe() {}
}

public final class Sub extends Super {
    private final Instant instant;
    Sub() { instant = Instant.now(); }
    @Override
    public void overrideMe() {
        System.out.println(instant);
    }
    public static void main(String[] args){
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

 

instant 가 두 번 출력된다고 기대하지만 첫 번째는 null 을 출력한다. 하위 클래스의 생성자가 해당 필드를 초기화하기도 전에 overrideMe 메서드가 불리기 때문이다.

 

네 번째, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안 된다.

Cloneable 과 Serializable 인터페이스를 구현한 클래스는 설계하기가 더 까다롭다. clone 메서드는 하위 클래스의 clone 메서드가 복제본의 상태를 수정하기 전에 재정의한 메서드를 호출할 수 있고, readObject 메서드는 하위 클래스의 상태가 미처 다 역직렬화하기 전에 재정의한 메서드를 호출할 수 있다. Serializable 인터페이스를 구현한 클래스가 readResolve 나 writeReplace 메서드를 갖는다면 이 메서드들은 private 가 아닌 protected 로 선언해야 한다.

 

가급적, 상속용으로 설계하지 않은 클래스는 상속을 금지하자.

클래스를 final 로 선언하거나 모든 생성자를 private 나 package-private 으로 선언하고, public 정적 팩토리 메서드를 만들어 두자. 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 준다.

 

만약 재정의 가능 메서드를 꼭 써야한다면, private 도우미 메서드로 옮기고 이 도우미 메서드를 호출하게끔 변경하자.

 

"상속용 클래스를 설계할 때 문서화를 잘 하자!

재정의 가능 메서드는 되도록이면 호출하지 말자"

반응형
반응형

ITEM 18 "상속보다는 컴포지션을 사용하라"

 

ITEM 17 과 마찬가지로 OCP(Open Close Priciple) 원칙에 대한 내용이다.

 

 

이 ITEM 을 확인하기 전에 위 우아한 테크코스 - OCP 와 전략패턴 영상을 보는 것을 추천한다.

OCP 원칙을 설명하면서 상속은 is-a 관계, 합성은 has-a 관계일 때 사용하여야 한 된다는 점과 템플릿 메서드 패턴과 전략 패턴의 확실한 차이도 확실히 배워갈 수 있다.

 

우아한 테크코스 영상은 초보 프로그래머가 알아야 되는 내용들을 잘 설명해준다.

 

이번 장에서는 상속과 비교하면서 합성 연관관계를 권장하고 있다.

 

상속을 사용하면 상위 클래스와 하위 클래스 개념이 생긴다.

상위 클래스가 버전이 릴리즈 될 때마다 내부 구현이 달라지면 그 여파로 하위 클래스가 오동작할 수 있다.

그래서 상위 클래스가 충분히 확장을 고려하지 않은 상태로 구현되면 캡슐화가 깨질 수 있다.

 

상속이 잘 되려면...

1 . 상위 클래스와 하위 클래스가 한 프로그래머가 통제하여야 상위 및 하위 클래스가 변경될 시 그 side-effect 를 잘 알고 같이 수정할 수 있다.

2 . 확장할 목적으로 설계되어 있어야 하고 문서화도 잘 되어 있어야 한다.

3 . 상위 클래스와 하위 클래스가 완벽히 IS-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);
    }
    
    public int getAddCount() {
    	return addCount;
    }
}

 

위와 같이 HashSet 이 처음 생성된 이후로 아이템이 몇 개가 더해졌는지 알기 위해 상속을 사용해서 addAll 과 add 메서드를 Override 했다고 생각해보자.

 

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));

 

재정의한 addAll 메서드를 호출하면 3을 반환하는 것이 아니라 6을 반환한다.

원인은 addAll 메서드는 add 메서드를 사용해서 구현하기 때문이다. add 함수에도 addCount 변수를 1증가시키고 있으니 6이 반환되는 것이다.

이런 내부 구현 방식은 HashSet 문서에 나와 있지 않다. HashSet 클래스를 만든 프로그래머가 이런 사실을 공유하지 않는다면 잘못된 방식으로 구현할 수 있다는 것을 보여준다. 심지어 HashSet 클래스의 addAll 메서드가 다음 릴리즈 때에도 구조가 유지될 것이라는 보장이 없다.

 

다시 메서드를 재정의한다면 되지 않을까?...

상위 클래스의 private 변수를 써야하는 상황이라면 아예 불가능하기도 하고, 상위 클래스의 메서드를 다시 재정의 해야 될 수도 있다. 성능, 상속을 한 이유가 사라짐, 오류 발생할 가능성 등 다양한 문제점이 생길 수 있다.

또 메서드를 재정의했는데 상위 클래스에서 추가한 메서드의 이름과 반환 타입이 같다면 컴파일조차 되지 않는다.

 

 

합성을 사용하자

새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조한다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 점에서 이러한 설계를 합성(composition)이라고 한다.

그리고 새로운 클래스의 메서드에서 기존 클래스에 대응되는 메서드를 호출하여 그 결과를 받는다.

 

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 ForwardingSet<E> implements Set<E> {
	private final Set<E> s;
    public ForwardingSet(Set<E> s) { this.s = s; }
    
    ... (생략) ...
    
    public boolean add(E e) { return s.add(e); }
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    
}

 

위와 같이 Set 을 클래스 내 변수로 생성해서 Set 의 메서드를 호출하는 전달 메서드를 정의하면 상위 클래스의 메서드 변경에도 유연하게 대처할 수 있다.

 

"상속은 강력하지만 캡슐화를 해칠 수 있다."

반응형
반응형

ITEM 17 "변경 가능성을 최소화하라"

 

제목을 보자마자 들었던 생각은 OCP(Open Close Priciple) 원칙이다.

"확장에 대해서는 열려 있어야 하고 수정에 대해서는 닫혀 있어야 한다"라는 뜻인데 이 원칙을 잘 지켜야 구조가 좋아지고 코드 품질이 훌륭해진다. 협업을 위해 반드시 지켜야 되는 개념이기도 하다.

 

이번 장에서는 불변 클래스로 만드는 것을 권장하고 있다.

 

불변 클래스

= 객체가 파괴되는 순간까지 인스턴스의 내부 값을 수정할 수 없는 클래스

 

불변 클래스 특징

이러한 불변 클래스가 되려면 아래와 같은 특징을 가져야 한다.

 

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

2 . 클래스를 확장할 수 없도록 한다. 하위 클래스가 상속하여 객체의 상태를 바꿀 수 있으므로 상속을 막기 위함이다.

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

4 . 모든 필드를 private 로 선언한다. 필드가 참조하는 가변 객체를 다른 쪽에서 수정한다면 side effect 가 발생한다.

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

TIP . 메서드들이 인스턴스 자신을 수정하지 않고 새로운 인스턴스를 반환하게 작성하면 안전하게 사용될 수 있다.

(이런 패턴을 함수형 프로그래밍이라고 한다. 메서드가 호출되어도 객체의 상태를 변경하지 않는 것을 의미한다.)

 

불변 객체는 가변 객체와 다르게 근본적으로 Thread-Safe 하며 따로 동기화할 필요가 없다.

또한 만약 가변 객체였다면 임의의 복잡한 상태에 놓일 수 있다. 끔찍하게 도미노처럼 side effect 가 연쇄적으로 발생할 수도 있다. 그래서 상태를 변경하는 메서드들의 상태 전이들을 정밀하게 문서화하지 않는다면 믿고 쓰기가 어렵다.

불변 객체는 실패 원자성을 제공한다. 불변 객체를 사용하는 임의의 메서드에서 예외가 발생한 후에도 불변 객체는 내부 상태를 바꾸지 않으니 실패하더라도 원자성을 제공한다.

 

안심하고 공유할 수 있기에 생성된 인스턴스를 최대한 재활용하는 것을 권장하고 있다. 아이템 1에서 소개한 정적 팩토리 메서드를 제공하는 것도 하나의 방법이다.

 

불변 클래스 단점

장점이 많아 보이는 불변 객체도 단점은 존재한다. 값이 다르다면 반드시 독립된 객체로 만들어야 한다.

불변 객체는 상수와 유사하기 때문에 값이 조금이라도 틀려진다면 새롭게 객체를 하나 생성해야 한다.

 

심지어 객체를 완성하는데 무거운 작업들이 수행되거나 중간 단계에서 만들어진 객체들이 모두 버려진다면 성능 이슈도 생기게 된다. 이 문제점을 해결하기 위해 2 가지가 있다.

 

1 . 다단계 연산을 제공하여 에측하여 기본 기능으로 제공한다.

2 . 다단계 연산이 예측이 되지 않는다면 가변 동반 클래스를 public 으로 제공한다.

 

불변 클래스를 만드는 방법

자신의 클래스를 상속하지 못하게 final 클래스로 선언하거나,

모든 생성자를 private 혹은 package-private 로 만들고 public 정적 팩토리를 제공하자.

 

성능을 위해서 불변 클래스 규칙을 완화

정책을 "어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다"로 변경한다.

외부 상태 값이 아니라 내부 상태 값만 변경해야 한다.

반응형
반응형

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

 

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

 

간혹 이렇게 데이터 필드 값들을 모아놓은 클래스들을 본 적이 있을 것이다.

데이터 전달용(DTO)이나 값 객체, 데이터 복사용 객체로 클래스의 기능이 많이 퇴보하긴 했지만 자주 사용한다.

 

이런 public 클래스를 선언할 때 필드 값들을 public 지시어로 두면 데이터 필드에 직접 접근할 수 있어 캡슐화의 이점을 제공하지 못한다. 불변식을 보장할 수 없으며 API 를 수정하지 않고는 내부 표현을 바꿀 수 없다. 외부에서 필드에 접근할 때 부수 작업을 수행할 수도 없다는 점도 크다.

 

class Point {
    private double x;
    private double y;

    public Point (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 클래스라면 getter 와 setter 접근자 메서드를 제공하고 내부 필드들은 private 로 감추자. 클래스 내부 표현 방식을 유연하게 바꿀 수 있다는 장점이 있다. package-private 클래스나 private 클래스라면 데이터 필드를 노출하더라도 문제가 없다. 같은 패키지안에서 사용하거나, 톱레벨 클래스에서만 접근하니 괜찮다.

반응형
반응형

ITEM 15 "클래스와 멤버의 접근 권한을 최소화하라"

 

이번 장(Item 15 ~ Item 25)부터는 자바라는 언어에 국한되어 서술되어 있는 것이 아닌 객체지향적으로 잘 설계하는 방법에 대해 서술하고 있다.

지금은 코틀린 라이브러리 공식 문서에서 사라져서 찾아볼 수 없지만, 코틀린 언어의 철학이 이 Item 들을 지키려고 노력했을 정도로 객체지향 세계에서 표준에 가깝다.

 

잘 설계된 컴포넌트는 클래스 내부 데이터와 내부 구현 로직들을 외부 컴포넌트로부터 철저히 숨겨두었다. 오직 API 를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 개의치 않는다.

정보은닉, 혹은 캡슐화라고 하며 이 개념은 소프트웨어 설계의 근간이 되는 원리다. 정보 은닉과 캡슐화를 철저히 해두었다면 아래와 같은 장점들이 있다.

 

1 . 시스템 개발 속도를 높인다.

컴포넌트를 독립적으로 기능 별로 분리할 수 있다면, 각 기능들을 개발할 수 있도록 개발자를 할당할 수 있다. 즉 컴포넌트를 병렬로 개발할 수 있다.

 

public interface MemberService {
	void joinMember(Member member);
    List<Member> getMembers();
    void leaveMember(Member member);
}

 

예를 들어 위와 같이 회원 가입에 필요한 서비스들("회원 가입", "회원 탈퇴", "회원 검색")을 나누어서 인터페이스로 제공하고 있다면, 각 팀원들이 한 가지 기능들을 맡아 개발할 수 있다.

 

2 . 시스템 관리 비용을 낮춘다.

소스코드 한 줄로 기능들이 뒤 섞여 있는 것보다 각 컴포넌트를 빨리 파악하여 디버깅 할 수 있고, 다른 컴포넌트로 교체하는 부담도 적다.

위 예시에서 "회원 검색" 에 대한 요구사항이 변경되었을 경우, getMembers 인터페이스의 구현체의 로직 일부분을 변경하기만 하면 된다.

 

3 . 성능 최적화에 도움을 준다.

정보 은닉 자체가 성능 향상이 되지는 않지만, 프로파일링해서 최적화할 컴포넌트를 정해서 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화 할 수 있다.

속도가 더 빠른 알고리즘으로 바꾼다던지, 입력/수정/읽기 세션의 크기를 보고 아키텍처를 변경할 수도 있다. 독립적이기에 가능하다.

 

4 . 소프트웨어 재사용성을 높인다.

다른 컴포넌트에 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 다른 곳에서도 충분히 재사용할 수 있다.

 

5 . 큰 시스템을 제작하는 난이도를 낮춰준다.

시스템 전체가 만들어지지 않아도 개별 컴포넌트의 동작을 검증할 수 있다. Mockito 를 이용해 가짜 객체를 생성하고 그 객체를 통해 테스트 케이스 작성이 가능해진다. DIP 원칙을 통해 인터페이스로 접지한 객체를 DI(dependency injection) 해서 독자적인 테스트 케이스를 만들 수도 있다.

 

public class MemberTest {
    private static class Member {
        private String name;
        private int age;
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public int getAge() { return age; }
        public void setAge(int age) { this.age = age; }
    }

    @Mock
    Member member;

    @Test
    public void 멤버별_테스트() {
        Member member = mock(Member.class);
        assertTrue(member != null);
        when(member.getName()).thenReturn("gold-egg");
        when(member.getAge()).thenReturn(29);
        assertTrue(member.getName() == "silver-egg");
        assertTrue(member.getAge() == 30);
    }
}

 

 

위와 같이 Member 에 대한 클래스만 따로 테스트 하는 것을 볼 수 있다.

 

정보 은닉은 모든 클래스와 멤버의 접근성을 가능한 좁히는 쪽으로 설계하면 자연스레 안전한 캡슐화가 된다.

자바에서는 아래와 같이 접근 제한자를 제공하고 있으며 클래스 (및 인터페이스), 변수, 메서드등 범용적으로 사용할 수 있다.

 

JAVA 의 접근제한자

private: 멤버를 선언한 top-level 클래스에서만 접근할 수 있다.
package-private: 멤버가 소속된 패키지 안의 모든 클래스에서 접근 할 수 있다.
protected: package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
public: 모든 곳에서 접근 할 수 있다.

 

클래스와 인터페이스 내에서 접근 제한 사항

top-level 클래스와 인터페이스는 package-private 과 public 두 가지이고 public 으로 선언하면 공개 API 가 되며, package-private 으로 선언하면 해당 패키지 안에서만 이용이 가능하다.

패키지 외부에서 사용할 것이 아니라면 package-private 로 선언하자. 반면 public 으로 선언한다면 API 가 되므로 하위 호환을 위해 영원히 관리해주어야 한다.

 

한 클래스에만 사용하는 private-package top-level 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static 으로 중첩시키자.

public 일 필요가 없는 클래스의 접근 수준을 package-private top-level 클래스로 좁히는 일이다. public 클래스는 그 패키지의 API 인 반면, package-private 톱레벨 클래스는 내부 구현에 속하기 때문이다.

 

만약, 공개 API 를 제외한 나머지 멤버들은 private 로 만들자. 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private 로 풀어두자. 권한을 자주 풀어준다면, 컴포넌트를 더 분해해야 되는 것은 아닌지 고민해야 한다.

public 클래스에서 멤버 접근 수준을 package-private 에서 protected 로 바꾸는 순간 그 멤버에 접근할 수 있는 대상 범위가 넓어지므로 public 클래스의 protected 멤버는 공개 API 이므로 영원히 지원돼야 한다.

내부 동작 방식을 API 문서에 적어 사용자에게 공개해야 할 수도 있다. Protected 멤버의 수는 적을수록 좋다.

 

참고로, 멤버 접근성을 못 좁히는 방해 제약도 있다. 상위 클래스의 메서드를 재정의할 때는 그 접근 수준을 상위 클래스보다 좁게 설정할 수 없다. (리스코프 치환 원칙)

 

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

클래스의 필드가 가변 객체를 참조하거나, final 이 아닌 인스턴스 필드를 public 으로 선언하면 그 필드에 담긴 값을 제한할 힘을 잃게 된다. 그 필드와 관련된 모든 것은 불변식을 보장할 수 없게 된다.

심지어 가변 필드가 수정될 때 다른 작업을 할 수 없게 되므로 thread-safe 하지도 않다. public 지시어는 될 수 있으면 삼가자.

예외는 있다. 상수의 경우에는 관례대로 public static final 지시어를 사용해 필드를 공개해도 좋다. 이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.

그렇다고 절대로 아래와 같이 배열을 public static final 로 선언하면 안 된다. 배열의 참조를 변경할 수 없겠지만, 배열 내 내용을 변경할 수 있는 보안 허점이 있다.

 

문제점

public static final Thing[] VALUES = {...};

 

해결책 1

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

 

배열을 private 로 만들고 public 불변 리스트로 변환하여 추가한다.

 

해결책 2

private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
	return PRIVATE_VALUES.clone(); //방어적 복사본
}

배열을 private 로 만들고 public 메서드를 추가한다.

 

"프로그램 요소들의 접근성은 가능한 최소한으로 하자!"

반응형
반응형

ITEM 14 "Comparable 을 구현할지 고려하라"

 

Comparable 인터페이스에는 compareTo 메서드가 있다.

얼핏 보면 Object 의 equals 메서드와 동일해 보이는데 단순 동치성 비교를 넘어서서 순서까지 비교할 수 있고 제네릭하다는 특징이 있다. 그래서 Comparable 인터페이스를 구현한 클래스들은 자연적인 순서(natural order)가 있다.

 

public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet<>();
        Collections.addAll(s, args);
        System.out.println(s);
    }
}

 

이 예제는 TreeSet 에 명령 인자들을 추가하는데 출력했을 때 알파벳순으로 출력된다. String 클래스 자체가 Comparable 인터페이스를 구현한 덕분이다. 이처럼 자바 플랫폼 라이브러리에 있는 값 클래스와 열거 타입이 Comparable 을 구현하고 있어 손쉽게 정렬을 할 수 있다.

 

Comparable 의 compareTo 메서드도 equals 와 유사한 규약을 가지고 있다.

 

compareTo 메서드 일반 규약

'이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 타입이 다른 객체가 주어진다면 ClassCastException 예외를 던진다.'

 

대칭성

sgn(x.compareTo(y)) == -sgn(y.compareTo(x))

-> 여기서 sgn 은 부호함수로 표현식이 음수면 -1, 0 이면 0, 양수이면 1을 반환한다.

추이성

x.compareTo(y) > 0 이고 y.compareTo(z) > 0 이면 x.compareTo(z) > 0

반사성

x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))

equals 와 동치 (권장사항)

(x.compareTo(y) == 0) == (x.equals(y))

 

hashCode 규약을 지키지 못하면 해시를 사용하는 클래스와 어울리지 못하듯, compareTo 규약을 지키지 못하면 비교를 활용하는 클래스들과 어울리지 못 한다. TreeSet 과 TreeMap 같은 정렬이 보장되어 있는 컬렉션들, 유틸리티 클래스인 Collections 와 Array 도 compareTo 규약을 지켜야만 제대로 동작한다.

 

마지막 4번째 규약은 꼭 지키라고 권장하고 있다.

빈 HashSet 인스턴스를 생성한 다음 new BigDecimal("1.0") 과 new BigDecimal("1.00") 을 차례대로 추가했다고 가정해보자. 이 두 BigDecimal 을 equals 메서드로 비교하면 서로 다르기 때문에 HashSet 은 2 개의 원소를 가진다. 하지만 TreeSet 에 담았다면 하나의 원소를 가지게 된다. equals 결과값과 compareTo 결과값이 다르면 나중에 컬렉션을 옮길 때 문제가 발생할 수 있다.

 

compareTo 메서드 올바르게 사용하기

 

public int compareTo(PhoneNumber pn) {
    int result = Short.compare(this.areaCode, pn.areaCode);
    if(result == 0) {
        result = Short.compare(this.prefix, pn.prefix);
        if(result == 0) {
           	result = Short.compare(this.lineNum, pn.lineNum);
        }
    }
}

 

Comparable 인터페이스는 제네릭 인터페이스이므로 인수 타입이 컴파일타임에 정해진다. 타입을 확인하거나 형변환할 필요가 없다. 인수의 타입이 잘못되었다면 컴파일 자체가 안 되어 Object 처럼 형변환을 안 해도 된다.

 

자바 7 이전에는 정수 기본 타입을 비교할 때 <, > 연산자를 사용하고 실수 기본 타입 필드를 비교할 때 정적 메서드인 Double.compare 와 Float.compare 을 사용하라고 권고했었다. 그런데 자바 7 도입 이후 박싱 타입 클래스에서 기본 타입에 대한 정적 메서드를 제공하고 있어 그냥 compare 로만 비교하면 된다.

 

위 예제에서 비교할 필드가 여러 개라면, 가장 핵심이 되는 필드부터 비교해나가자.

비교 결과가 0 이 아니라면 순서가 결정되었으니 바로 리턴하고, 같다면 그 다음 주요 필드를 계속해서 비교하면 된다.

 

자바 8에서는 Comparator 인터페이스가 비교자 생성 메서드를 제공하고 있다.

약간의 성능 저하가 있지만, 함수형 프로그래밍처럼 메서드 연쇄 호출을 할 수 있어 아래와 같이 코딩하기도 한다.

 

    private static final Comparator<PhoneNumber> COMPARATOR 
            = comparingInt((PhoneNumber pn) -> pn.areaCode)
            .thenComparingInt(pn -> pn.prefix)
            .thenComparingInt(pn -> pn.lineNum);

 

이해 안 되는 부분이지만,,,

솔직히 마지막은 이해가 안 된다. hashcode 함수를 거친 결과 값들이 입력 값의 순서대로 증감을 가진다면 의미가 있겠지만, 내가 알기로는 해시함수가 순서를 보장해주지 않는다. 책에서 소개한 것처럼, hashcode 를 비교할 일은 없을 것 같다.

 

static Comparator<Object> hashCodeOrder = new Comparator<>() {
	(Object o1, Object o2) -> o1.hashCode() - o2.hashCode();
}

 

여담이지만 그래도 진짜 이 코드가 무슨 의미일까 한참을 생각했다. (Object 의 hashCode 는 순서를 보장해주나?)

hashCode 를 비교하기 보다는 "-" 에 의미를 맞춰 소개한 것 같다. - 를 사용하면 Int 의 경우 정수 오버플로우를 일으킬 가능성이 있고 Float 의 경우 부동소수점 계산 방식에 따른 오류가 발생할 수 있다.

 

static Comparator<Object> hashCodeOrder = new Comparator<>() {
	(Object o1, Object o2) -> Integer.compare(o1.hashCode(), o2.hashCode())
    }
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

 

- 비교연산자를 사용할 바에는 정적 compare 메서드를 활용하거나 비교자 생성 메서드를 활용하자.

 

"순서를 고려해야 하는 값 클래스를 작성한다면 Comparable 인터페이스를 구현하라"

 

반응형
반응형

ITEM 13 "clone 재정의는 주의해서 진행하라"

 

현재 객체를 지금 상태로 복사해서 생성하고 싶을 때가 있다.

자바에서는 최고 조상 Object 에서 clone 메서드를 통해 객체를 복사 생성할 수 있게 기능을 제공하고 있다. 하지만 하위 클래스의 어떤 필드가 있는지 어떻게 알고 객체를 복사해준다는 것일까?...

 

Cloneable 이라는 인터페이스를 추가로 제공하고 있는데 인터페이스에서 Object clone 메서드의 동작 방식을 결정한다. (Cloneable 인터페이스 구현이 공개되어 있지 않아 정확한 동작 방식은 모르지만, Object clone 메서드 내용을 바꾸는 것으로 추정된다.) 하위 모듈에서 상위 모듈의 동작 방식에 관여한다는 것이다. 객체지향 세계에서는 상위 모듈에서 하위 모듈로 실행흐름이 전이되는 것이 국룰인데 지금 방식은 하극상(?) 지원하는 것이다.

 

심지어 Object clone 메서드가 protected 라서 단순히 Cloneable 인터페이스를 구현한다고 하여 clone 메서드를 사용할 없다. Cloneable 인터페이스를 구현한 클래스의 인스턴스에서 clone 메서드를 호출해야만 객체의 필드들을 하나씩 복사해 객체를 반환한다. Cloneable 인터페이스를 구현하지 않은 클래스에서 clone 메서드를 호출하면 CloneNotSupportedException 예외를 던진다.

 

Object 정의된 clone 메서드의 일반 규약도 허술하다.

x.clone() != x, x.clone().getClass() == x.getClass() 속성만 필수로 지원하고 x.clone().equasl(x) 속성이 필수는 아니라고 적혀있다.

동치성을 보장하지 않는다면 복사한 의미가 있을까? 같은 객체라고 있을까? 필수 속성만 고려한다면 생성자로도 충분히 인스턴스를 반환할 있다. super.clone 호출하지 않고 생성자를 생성해서 반환한다면 클래스를 상속 받은 하위 클래스에서 super.clone 메서드를 호출했을 잘못된 클래스의 객체가 만들어지는 불상사가 생긴다.

 

cloneable 인터페이스와 clone 메서드 제대로 활용하기

위험한 설계를 가진 복사체계이지만, 여전히 많은 자바 라이브러리에서 사용한다고 한다. 제대로 활용하는 방법은 숙지할 필요가 있다.

 

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

 

모든 필드가 기본 타입이거나, 불변 객체를 참조한다면, 위와 같이 super.clone 메서드만 호출하고 PhoneNumber 객체로 로 변환해서 반환하기만 하면 된다. (covariant return typing)

 

문제는 가변 객체를 참조할 때이다. 이 문제는 shallow copy 문제점이라 다른 언어에서도 고려해야 하고 맨 마지막에서 서술할 복사 생성자, 복사 팩토리 메서드에 작성할 때도 고려해야 할 대상이라는 점임을 참고하자.

 

public class Stack implements Cloneable{
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    /* 생략 */

    @Override
    public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            result.elements = elements.clone();
            return result;
        } catch(CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

 

위 스택 코드에서 size 필드는 기본 타입이므로 올바른 값을 가지지만, 복사된 elements 배열은 원본 Stack 인스턴스의 elements 배열을 참조할 것이다. 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다. 이런 문제점을 shallow copy 라고 한다. 이 반대 개념이 deep copy 라고 하는데 참조 변수들을 모두 생성하여 내용을 복사한다.

 

다행히도 배열 내부에 정의된 clone 메서드가 충분히 제 역할을 하고 있다. 결과를 Object[] 로 형변환하지 않아도 알아서 원본 배열과 같은 타입을 반환한다. 그래서 위와 같이 배열의 clone 문구만 추가해주면 끝난다.!

 

그런데 만약 저 elements 필드가 final 이라면 복제할 수가 없다. 이는 Cloneable 아키텍처가 '가변 객체를 참조하는 필드는 final 로 선언하라' 라는 용법과 충돌한다. 복제가 가능한 클래스로 만드려면 final 한정자를 제거해야 될 수도 있다.

 

public class HashTable implements Cloneable  {
    private Entry[] buckets = ...;
    private static class Entry {
        final Object key;
        Object value;
        Entry next;

        Entry(Object key, Object value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
        Entry deepCopy() {
            Entry result = new Entry(key, value, next);
            for(Entry p=result; p.next!=null; p=p.next)
                p.next = new Entry(p.next.key, p.next.value, p.next.next);
            return result;
        }
    }

    @Override
    public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = new Entry[buckets.length];
            for(int i= 0; i< buckets.length; i++)
                if (buckets[i] != null)
                    result.buckets[i] = buckets[i].deepCopy();
            return result;
        } catch(CloneNotSupportedException e) {
            throw new Assertion();
        }
    }
}

 

이번엔 객체를 담은 배열이다. 객체 내부에 연결 리스트가 있어서 위와 같이 배열이라고 생각하고 clone 만 명시할 경우 같은 객체를 참조하게 된다. 위와 같이 직접 모두 생성해서 연결해주어야 한다.

 

끝으로 이런 상위 클래스에 의존하는 설계는 하위 클래스에서 독이 된다. 상속용 클래스는 Cloneable 을 구현해서는 안 된다. 제대로 clone 메서드를 작성해도 하위 클래스에서 cloneable 구현 여부를 직접 결정한다.

또, Object 의 clone 메서드는 thread-safe 하지 않다. 동기화를 신경쓰지 않았다. 재정의해서 동기화해주어야 한다.

 

요약하면, 아래와 같다.

1 . Cloneable 인터페이스를 구현한 모든 클래스는 clone 메서드를 재정의해야 한다.

이 때 clone 메서드는 public 이어야 하며, 반환 타입은 자신의 클래스로 변경해야 한다.

2 . 이 메서드는 가장 먼저 super.clone() 을 호출하여 주요 필드를 전부 적절히 수정한다.

이 객체 내부에 있는 모든 가변 객체들을 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체들을 가리켜야 한다.

다시 말해, 원본 객체의 필드와 공유되지 않도록 deep copy 를 해야 한다는 소리이다.

=> 가급적 사용하지 말자. 복잡하다.

 

복사 생성자와 복사 팩토리 메서드가 더 나을 수 있다.

사실 자바 clone 메서드는 다른 언어에서 복사 생성자와 같은 역할을 한다. 같은 유형의 객체를 복사 생성하면서 독립적인 객체를 만들어 준다는 면에서 거의 똑같다. 복사 생성자와 복사 팩토리 메서드는 자신과 같은 클래스의 인스턴스를 받아 자기 자신의 인스턴스를 생성한다. 프로그래머 입장에서 더 유연하고 clone 을 충분히 대체할 수 있다.

 

대체할 수 있다고 해서 위에서 소개한 가변 필드들을 deep copy 안 하면 안 된다. 단지 선언 공간만 바뀐 것이다.

 

shallow copy 의 위험성

deep copy 안 했을 경우, 다른 객체를 통해 필드들을 제어할 수 있어 보안사고로 이어진다. 아래는 C++ 의 shallow copy 의 위험성을 알리기 위해 만들어진 pwn 문제(바이너리의 취약점을 찾아 쉘을 획득하는 문제) 로 풀어보는 것을 권장한다. pwnable.tw(pwnable.tw 의 Ghost Party 문제)

 

필자는 풀었다.

 

반응형

+ Recent posts