반응형

ITEM 23 "태그 달린 클래스보다는 클래스 계층구조를 활용하라"

 

솔직히 이번 아이템은 공감하기가 매우 어려웠다.

태그 달린 클래스를 사용하지 말라고 권장하는데 사실 저런 혼종 클래스는 처음 봤다. ㅋㅋㅋ

태그 달린 클래스란 두 가지 이상의 의미를 표현할 수 있는 클래스로 내부에 enum 열거 타입이 존재한다는 특징이 있다. 두 가지 이상의 의미를 가지고 있기 때문에 생성자도 따로 필드들도 따로 구현되어야 한다.

 

클래스는 하나의 기능만 가져야 하는 SRP(Single Responsibility Principle) 원칙을 지켜야 한다.

솔직히 이번 아이템은 공감하기가 매우 어려웠다.

태그 달린 클래스를 사용하지 말라고 권장하는데 사실 저런 혼종 클래스는 처음 봤다. ㅋㅋㅋ 태그 달린 클래스란 두 가지 이상의 의미를 표현할 수 있는 클래스로 내부에 enum 열거 타입이 존재한다는 특징이 있다. 두 가지 이상의 의미를 가지고 있기 때문에 생성자도 그 갯수만큼 있어야 하고, 필드 변수들도 그 갯수만큼 있어야 한다. 또 타입별로 다른 로직이 필요하므로 각종 switch 구문과 조건문들이 난재되어 있다.

 

public static class Figure{
	enum Shape { RECTANGLE, CIRCLE };
    // 현재 태그 필드. 모양
    final Shape shape;

	// 사각형에서 사용할 필드들
    double length;
    double width;

	// 원에서 사용할 필드
    double radius;

	// 원 생성자
    Figure(double radius) {
    	shape = Shape.CIRCLE;
        this.radius = radius;
    }

	// 사각형 생성자
    Figure(double length, double width) {
    	shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }
    
    double area() {
    	switch(shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw newAssertionError(shape);
        }
    }
}

 

가독성도 나쁘고, 다른 의미를 가진 코드들도 언제나 메모리에 상주해야 하며, 해당 의미에 쓰이지 않는 데이터 필드들을 생성자 시점에서 초기화해야 한다. 심지어 엉뚱한 필드를 초기화해도 런타임에야 문제가 드러난다. 다른 의미를 추가할 때마다 switch 문들도 수정해야 한다. 마지막으로 인스턴스의 타입만으로 현재 나타내는 의미를 알 길이 전혀 없다.

 

아무래도 아래 원칙을 강조하고자 위와 같은 혼종 클래스는 사용하지 말라고 권장하는 것 같다.

 

클래스는 하나의 기능만 가져야 하는 SRP(Single Responsibility Principle) 원칙을 지켜야 한다. 하나의 클래스에서는 하나의 기능만 가져야 유지보수하기도 편하고, 객체지향적으로 잘 작성할 수 있다. 여러가지 의미를 서술하고 싶으면 클래스 계층구조를 활용하라는 것이다.

 

가장 먼저 계층구조의 루트가 될 추상 클래스를 정의하고, 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 메서드로 선언한다. 그러고 모든 하위 클래스에서 공통으로 사용하는 데이터 필드들을 전부 루트 클래스로 올린다.

위에 서술한 Figure 클래스를 클래스 계층 구조로 변환해보자.

 

abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;
    Circle(double radius) { this.radius = radius; }
    @Override double area() { return Math.PI * (radius * radius); }
}

class Rectangle extends Figure {
    final double length;
    final double width;
    Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
    @Override double area() { return length * width; }
}

 

관련없는 데이터들이 사라지고, 더러운 switch 문까지 모두 사라졌다. 실수로 빼먹은 case 문 때문에 런타임 오류가 발생할 염려도 없다. 이런 식으로 설계하면, 루트 클래스의 코드를 건드리지 않고도, 다른 프로그래머들이 계층구조를 확장해서 함께 사용할 수 있다.

 

또한 타입 사이의 자연스러운 계층 관계가 반영되어 유연성은 물론 컴파일타임 타입 검사 능력을 높여준다는 장점도 있다.

 

"태그 달린 클래스를 가급적 지양하고 차라리 계층구조의 클래스를 사용해보자."

반응형
반응형

ITEM 22 "인터페이스는 타입을 정의하는 용도로만 사용하라"

 

상수들을 다른 클래스에서 사용하고자 할 때 자바가 아니였다면 전역적으로 사용하는 상수들을 한 파일에 모아두고 그 상수를 참조해서 쓰면 되는데, 자바에서는 어느 임의의 스코프에 상수들을 따로 모아 관리해야 된다고 생각할 수 있다.

 

가장 간단하게 정리할 수 있을 것 같아 보이는 영역이 인터페이스이다.

 

public interface PhysicalConstants {
	static final double AVOGADROS_NUMBER = 6.022_140_857e23;
    	static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    	static final double ELECTRON_MASS = 9.109_383_56e-31;
}

 

하지만 이런 상수 인터페이스는 인터페이스를 잘못 사용한 예이다. 상수 인터페이스란 메서드 없이, 상수를 뜻하는 static final 필드로만 기재되어 있는 인터페이스를 말한다. 인터페이스는 추상 클래스와 성격이 비슷하다. 가장 상위에 있는 참조 지역인 이 공간에 상수를 둔다면, 이전 아이템에서 살펴보았듯이 캡슐화가 깨지게 된다. 더 이상 그 상수들을 쓰지 않게 되더라도 호환성을 위해 해당 인터페이스를 구현하고 있어야 하며, 참조가 가능해져 하위 클래스들이 같이 오염된다.

 

상수를 공개할 목적이라면, 차라리 그 클래스나 인터페이스 자체에 직접 추가하는 것이 올바르다. 아니면, 인스턴스화 할 수 없는 유틸리티 클래스에 담아 공개하는 것이 좋다.

 

public class EffectiveJavaTest {
    // 인스턴스화 방지
    private EffectiveJavaTest() {}
    public static final double CUSTOM_PERIMETER = 3.141592;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
    public static final double BOLTZMANN_CONST = 1.380_648_52e-23;
}

 

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

클래스 입장에서는 자신의 인스턴스로 무엇을 할 수 있을지 클라이언트에게 알려주는 용도. 딱 그 정도이어야 한다.

클래스 내부에서 사용하는 상수 변수를 인터페이스로 올려서 뺀다면 내부 구현을 노출하는 행위와 같다는 사실을 명심하자.

반응형
반응형

ITEM 21 "인터페이스는 구현하는 쪽을 생각해 설계하라"

 

인터페이스를 한 번 설계하고 릴리즈하면 다시 수정하기가 매우 어렵다.

메서드를 추가하고 싶어도 그 인터페이스의 구현체들을 모두 찾아 직접 수정해야 되기 때문이다. 자바 8 이후에는 이러한 불편을 해소하고자 인터페이스에 메서드 구현을 추가할 수 있도록 디폴트 메서드를 지원하기 시작했다.

 

디폴트 메서드를 선언하면, 그 인터페이스를 구현한 모든 클래스에서 재정의하지 않아도 사용할 수 있다. 기존 인터페이스에 메서드를 추가하는게 쉬어졌지만, 사실 구현체들은 인터페이스에서 새로운 메서드가 추가되었는지 알 수 없다. 그러니 모든 상황에서 불변식을 해치지 않는 디폴트 메서드를 작성하기가 어렵긴 마찬가지다.

 

 

자바 8 부터 Collection 인터페이스에 추가된 디폴트 메서드인 removeIf() 를 살펴보자. 이 메서드는 인자로 주어진 Boolean 함수(predicate)가 true 를 반환하는 모든 원소를 제거한다. 그런데 이 디폴트 메서드가 현존하는 모든 Collection 구현체와 잘 어우러지는 것은 아니다.

 

아파치 커먼즈 라이브러리의 org.apache.commons.collections4.collection.SynchronizedCollection 클래스는 java.util의 Collections.synchronizedCollection 정적 팩토리 메서드가 반환하는 클래스와 비슷하다. 아파치 버전은 클라이언트가 제공한 객체로 락을 거는 능력을 추가로 제공합니다. 즉, 모든 메서드에서 주어진 락 객체로 동기화한 후 내부 컬렉션 객체에 기능을 위임하는 래퍼 클래스이다.

 

아파치의 SynchronizedCollection 클래스는 처음에 removeIf 메서드를 재정의하지 않고 있었다.

(현재는 지원되고 있음. https://commons.apache.org/proper/commons-collections/apidocs/org/apache/commons/collections4/collection/SynchronizedCollection.html)

 

만약 재정의되지 않았던 옛날 버전의 클래스를 자바 8과 함께 사용한다면(removeIf 의 디폴트 구현을 물려받게 된다면), removeIf 의 구현이 동기화에 관해 아무것도 모르기 때문에 락 객체를 사용할 수 없게 된다. 따라서 SynchronizedCollection 인스턴스를 여러 쓰레드가 공유하는 환경에서 한 쓰레드가 removeIf를 호출하면 ConcurrentModificationException이 발생하거나 다른 예기치 못한 결과로 이어질 수 있다.

 

그래서 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요한 경우가 아니라면 피하자.

새로운 인터페이스를 만드는 경우라면, 표준적인 메서드 구현을 제공하는데 유용한 수단이 될 수 있다. 이전 아이템에서 설명했듯이 해당 인터페이스를 활용하는 클라이언트도 여러 개 만들어서 의도한 용도에 맞게 잘 부합되는지 확인하자.

반응형
반응형

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 클래스라면 데이터 필드를 노출하더라도 문제가 없다. 같은 패키지안에서 사용하거나, 톱레벨 클래스에서만 접근하니 괜찮다.

반응형

+ Recent posts