반응형

ITEM 27 "비검사 경로를 제거하라"

 

제네릭을 사용하기 시작하면, 수많은 컴파일러 경고를 보게 된다.

비검사 형변환 경고, 비검사 메서드 호출 경고, 비검사 매개변수화 가변인수 타입 경고, 비검사 변환 경고 등 수많은 비검사 경고들을 볼 수 있다. 비검사 경고란, 컴파일 시에 컴파일러가 자바 코드를 컴파일하고 나서 잘못된 것들에 대해 경고하는 옵션을 의미한다. javac 명령 인수에 -Xlint:uncheck 를 추가하면 해당 에러를 볼 수 있다.

 

public class EffectiveJavaTest {
    static class Test {
        Test() {}
    }
    static Set<Test> testSet = new HashSet();
    public static void main(String[] args) {
        testSet.add(new Test());
    }
}

 

위 코드를 -Xlint:unchecked 옵션과 함께 컴파일하면 unchecked conversion 경고가 발생한다. HashSet 에 다이아몬드 연산자를 추가해 실제 타입 매개변수를 추론해야만 경고가 사라진다. 이렇게 가능한 모든 비검사 경로를 제거해야 한다. 그래야 런타임 시에 ClassCastException 이 발생할 일이 없고, 의도한 대로 잘 동작할 수 있다.

 

경고를 제거할 수는 없지만, 타입 안전하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 애너테이션을 달아 경고를 숨기자. 단, 애너테이션을 다는 개발자가 직접 타입 안전함을 검증하지 않은 채 경고를 숨기면 경고 없이 컴파일은 되겠지만, 런타임시에 ClassCastException 이 발생할 수 있다.

 

@SuppressWarnings 애너테이션은 개별 지역변수 선언부터 클래스 전체까지 어떤 선언에도 달 수 있다. 하지만 가능한 좁은 범위에 적용해야 된다. 변수 선언이나 짧은 메서드, 생성자에 적용해야 한다. 어디에서 경고가 발생할지 모르니 절대 클래스 전체에 적용하면 안 된다.

 

public class EffectiveJavaTest {
    // 수정 전
    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            return (T[]) Arrays.copyOf(elements, size, a.getClass());
        System.arraycopy(elements, 0, a,0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }
    
    // 수정 후
    public <T> T[] toArray(T[] a) {
        if (a.length < size) {
            @SuppressWarnings("unchecked")
            T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass());
            return result;
        }
        System.arraycopy(elements, 0, a,0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }
}

 

수정 전 코드를 보면 Arrays.copyOf 메서드에 unchecked cast 경고가 발생한다. 애너테이션은 선언에만 달 수 있기 때문에 return 구문에 적용할 수 없다. 그래서 return 지역 변수를 하나 만들고 그 변수에 애너테이션을 만들어야 한다. 범위도 줄일 수 있고 깔끔해진다. 애너테이션을 사용할 때면 그 경고를 무시해도 안전한 이유를 항상 주석으로 남기자.

반응형
반응형

ITEM 26 "로 타입은 사용하지 말라"

 

같은 기능을 하는 코드이지만 자료형에 따라 오버로딩 함수를 여러 개 만들어야 한다고 생각해보자.

자연스레 타입에 상관없이 제네릭하게 코드를 구성하고 싶을 것이다. 자바에서도 타입을 매개변수로 하여 클래스와 인터페이스를 작성할 수 있으며 제네릭 클래스 혹은 제네릭 인터페이스라고 한다. 두 정의를 통틀어 제네릭 타입이라 한다.

 

꺽쇠괄호 안에 실제 타입 매개변수들을 나열해서 매개변수화 타입을 정의한다. 예를 들어 List<String> 과 같은 식이다.

이 제네릭 타입을 정의하면 로 타입도 함께 정의되는데 이 로 타입을 주의해야 한다. List<String> 의 로 타입은 List 이다. 선언 시에 타입에 대한 정보가 지워진 것처럼 동작하는데 하위 호환성을 위해 자바 9 까지 계속 허용하고 있다.

 

public class EffectiveJavaTest {
    static class Stamp {
        public Stamp () {}
        public void cancel() {}
    }
    static class Coin {
        public Coin () {}
    }
    private final static Collection stamps = new ArrayList<>();

    public static void main(String[] args) {
        stamps.add(new Coin());
        for (Iterator i = stamps.iterator(); i.hasNext();) {
            Stamp stamp = (Stamp) i.next();
            stamp.cancel();
        }
    }
}

 

선언 시에 타입에 대한 정보가 없으니 아무런 원소를 넣어도 컴파일 오류가 발생하지 않는다. 위 코드처럼 반복자로 원소를 비교하거나 꺼낼 때 런타임 오류가 발생한다. 원소 추가는 Coin 클래스로, 원소 활용은 Stamp 클래스로 할 수 있는 것을 볼 수 있다. 항상 느끼지만 자유도가 높으면 프로그래머의 실수를 유발하는 것 같다.

 

Collection<Stamp> 로 타입을 지정해준다면, 컴파일 타임 때 오류를 발견할 수 있다. 런타임 오류의 경우, 문제 발생 코드와 원인을 제공하는 코드가 물리적으로 상당히 떨어지면 어디서 오류가 발생했는지 모든 소스코드를 다 뒤져야 할 수도 있다. 그런데 컴파일 타임 때 오류를 발생하면 타입 안전하게 코드를 작성할 수 있다.

 

정말 로 타입을 쓰고 싶다면, List<Object> 처럼 임의의 객체를 허용하는 매개변수화 타입을 권장하고 있다.

매개변수로 List 를 받는 메서드에 List<String> 을 넘길 수 있지만, List<Object> 를 받는 메서드에는 넘길 수 없다.

 

더 나아가, 원소의 타입을 몰라도 되는 로 타입을 쓰고 싶을 때 <?> 비한정적 와일드카드를 쓸 것을 권장하고 있다.

<?> 는 < ? extends Object > 의 줄임 표현으로 어떤 자료형의 객체도 매개변수로 받겠다는 의미이다. 로 타입하고 같아보이지만, 처음에 넣은 타입 외에는 다른 원소를 넣을 수 없다는 특징이 있다.

 

public class EffectiveJavaTest {
    private static int test(Set<?> s1, Set<?> s2) {
        s1.add("test");
    }
    public static void main(String[] args) {
        Set<Integer> s1 = new HashSet<>();
        s1.add(Integer.valueOf(10));
        Set<Integer> s2 = new HashSet<>();
        s2.add(Integer.valueOf(20));
        test(s1, s2);
    }
}

 

< ? extends Object > 라고 많은 블로그에서 정의하고 있지만 사실 상 <? extends 입력된 첫번째 자료형> 이지 않을까 싶다. 그대로 컴파일하면 아래와 같은 에러를 내뿜는다. Integer 용 Set 에 String 을 add 하기 때문에 타입 컨벤션 오류가 발생한 것이다.

 

error: incompatible types: String cannot be converted to CAP#1
        s1.add("test");
               ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?

 

컴파일 단계 때, 타입 검사를 할 수 있어 로 타입보다 더 좋아보이지만 extends 와 super 키워드 없이 (상한제한자, 하한제한자) 단독으로 사용하지 않을 것을 권장하고 있다. 자세한 내용은 아래 링크를 참조하는 것을 추천한다. 

사용법 - Java에서 클래스 <?>은 무엇을 의미합니까? - 스택 오버플로 (stackoverflow.com)

 

그렇다고 로 타입을 아예 안 쓰지는 않는다. 로 타입을 써야되는 경우는 두 가지이다.

class 리터럴에는 로 타입을 써야 한다. 어떤 클래스를 표현하고자 할 때 xxx.class 로 표기하는데 여기에 제네릭 타입을 쓰면 안 된다. 두 번째는 instanceof 연산자이다. instanceof 연산자의 비연산자도 굳이 제네릭 타입을 쓸 필요가 없다.

 

"로 타입은 지양하자."

반응형
반응형

ITEM 25 "탑 레벨 클래스는 한 파일에 하나만 담으라"

 

소스 파일 하나에 탑 레벨 클래스를 여러 개 선언해도 자바 컴파일러는 에러를 발생시키진 않는다.

하지만 아무런 득이 없고, 심각한 위험을 감수해야 한다. 한 파일에 여러 클래스를 정의하는데 다른 파일에서도 같은 클래스를 정의할 수 있기 때문에 어느 소스 파일을 먼저 컴파일하느냐에 따라 다른 로직이 수행될 수 있다.

 

public class Utensil {
    static final String NAME = "pan";
}

public class Dessert {
	static final String NAME = "cake";
}

Utensil.java

 

public class Utensil {
    static final String NAME = "pot";
}

public class Dessert {
	static final String NAME = "pie";
}

Dessert.java

 

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }
}

Main.java

 

 

Utensil.java 및 Dessert.java 파일에 같은 클래스가 정의되었을 때 어떤 순서대로 컴파일하느냐에 따라 결과가 달라진다.

 

예를 들어, "javac Main.java Dessert.java" 명령으로 컴파일한다면 컴파일 오류가 난다. 가장 먼저 Main.java 를 컴파일하고, 그 안에서 Utensil 참조를 만나 Utensil.java 파일을 살펴 Utensil 과 Dessert 를 모두 가져온다. 그리고 나서 두 번째 명령줄 인수인 Dessert.java 를 처리하려고 할 때 같은 클래스에 대한 정의가 이미 있어 컴파일 오류가 나는 것이다.

 

"javac Main.java" 나 "javac Main.java Utensil.java" 명령으로 컴파일하면 Dessert.java 참조가 없기 때문에 "pancake" 를 출력한다. 반대로 "javac Dessert.java Main.java" 명령으로 컴파일하면 "potpie" 를 출력한다.

 

컴파일 순서에 따라 결과가 달라지는 문제를 해결하려면, 단순히 탑 레벨 클래스들을 서로 다른 소스 파일로 분리하면 된다. 굳이 여러 탑 레벨 클래스를 한 파일에 담고 싶다면, 정적 멤버 클래스를 고민해보는 것이 좋을 수 있다.

 

Intellij 에서 자바 파일 이름과 다른 클래스가 탑 레벨로 생기면 알아서 아래처럼 경고문구가 발생하면서 컴파일이 되지 않는다. "Class xxxx is public, should be declared in a file named '자바 이름'.java"

그래서 이번 아이템은 솔직히 강제로 지킬 수 있어 걱정 안해도 될 것 같다. ^^

 

반응형
반응형

ITEM 24 "멤버 클래스는 되도록 static 으로 만들어라"

 

클래스 내부에 변수처럼 중첩 클래스를 둘 수 있다. (nested class)

자신을 감싼 바깥 클래스에서만 쓸 수 있으며, 그게 아니라면 탑 레벨 클래스로 바꾸어야 한다. 이런 중첩 클래스는 정적 멤버 클래스, 비정적 멤버 클래스, 익명 클래스, 지역 클래스 4가지로 분류할 수 있다.

 

정적 멤버 클래스는 다른 클래스 안에 선언되고, 바깥 클래스의 private 멤버에도 접근할 수 있다. 그 이외에는 일반 클래스와 동일하다. 주로 바깥 클래스와 함께 쓰일 때만 사용하는 public 도우미 클래스로 쓰인다. 예를 들어 Calculator 클래스의 Operation.PLUS 와 MINUS 같은 형태로 연산을 참조할 때 정적 클래스로 표현하면 좋다.

 

public class EffectiveJavaTest {
    private String name;
    static class StaticClass {
        void hello() {
            EffectiveJavaTest effectiveJavaTest = new EffectiveJavaTest();
            effectiveJavaTest.name = "gold-egg";
            System.out.println(effectiveJavaTest.name);
        }
    }
    public static void main(String[] args) {
        EffectiveJavaTest.StaticClass staticClass = new StaticClass();
        staticClass.hello();
    }
}

 

비정적 멤버 클래스는 구문 상으로 static 키워드가 빠진 내부 클래스를 말한다. 비정적 멤버 클래스의 인스턴스가 바깥 클래스의 인스턴스와 암묵적으로 연결되어 있어서 비정적 멤버 클래스에서 "클래스명.this" 형태로 바깥 클래스 필드를 참조할 수 있다. 다른 클래스지만, 매우 강한 결합으로 묶일 수가 있다.

또 비정적 멤버 클래스는 바깥 인스턴스 없이 생성할 수 없다는 단점이 있다. 멤버 클래스가 인스턴스화 될 때 관계가 확립되며, 더 이상 변경할 수 없다. 아래 코드와 같이 바깥 클래스에서 new 로 생성하는데 진짜 드물게 바깥 인스턴스의 클래스.new MemberClass(args) 형태로 호출해 수동으로 만들기도 한다고 한다.

 

public class EffectiveJavaTest {
    private final String name;
    public EffectiveJavaTest(String name) {
        this.name = name;
    }
    public String getName() {
        NonStaticClass nonStaticClass = new NonStaticClass("noneStatic-class : ");
        return nonStaticClass.getNameWithOuter();
    }

    private class NonStaticClass {
        private final String noneStaticName;

        public NonStaticClass(String noneStaticName) {
            this.noneStaticName = noneStaticName;
        }

        public String getNameWithOuter() {
            return noneStaticName + EffectiveJavaTest.this.name;
        }
    }
    public static void main(String[] args) {
        EffectiveJavaTest noneStaticClass = new EffectiveJavaTest("g-egg");
        System.out.println(noneStaticClass.getName());
    }
}

 

이런 비정적 멤버 클래스는 어댑터를 정의할 때 쓰인다. 어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용할 때이다. Map 인터페이스의 keySet, entrySet, values 메서드가 반환하는 뷰들이 비정적 멤버 클래스를 사용한다고 한다. (열심히 찾아 보았지만 안 나온다... 하하)

 

장단점이 확실해서 멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면, 무조건 static 을 붙여 정적 멤버 클래스로 만들라고 권고하고 있다. 앞서 이야기 했듯이, 비정적 멤버 클래스는 숨은 외부 참조가 생겨 이 참조를 저장하면서 시간과 공간이 소비된다. 더 심각한 문제는 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못 할 수도 있다. 언제까지 참조가 살아있는지 체크해야 되는데 눈에 보이지 않기 때문이다.

 

익명 클래스는 말 그대로 이름이 없고 바깥 클래스의 멤버가 아닌 클래스를 말한다. 쓰이는 시점에 선언과 동시에 인스턴스가 만들어지며 어디서든 만들 수 있다. 비정적인 문맥에서만 사용될 때 바깥 클래스의 인스턴스 참조가 가능하다.

 

public class EffectiveJavaTest {
    private String name;
    public void hello() {
        HelloWorld helloBot = new HelloWorld() {
            @Override
            public void hello() {
                System.out.println("g-egg");
            }
        };
        helloBot.hello();
    }

    interface HelloWorld {
        void hello();
    }
}

 

선언한 지점에서만 인스턴스를 만들 수 있고, 여러 인터페이스를 구현할 수 없고 인터페이스를 구현하는 동시에 다른 클래스를 상속할 수도 없다. instanceof 검사나 클래스의 이름이 필요한 작업은 수행할 수도 없다. 익명 클래스를 사용하는 클라이언트는 그 익명 클래스가 상위 타입에서 상속한 멤버 외에는 호출할 수 없다. 가독성도 떨어진다.

 

자바가 람다를 지원하기 전에는 익명 클래스를 주로 사용했는데 이제는 이런 익명 클래스는 람다를 써도 된다. 이제는 정적 팩터리 메서드를 구현할 때 빼고 쓰지 않는다.

 

지역 클래스는 자주 쓰이지 않는다. 지역 변수를 선언할 수 있는 곳이라면 실질적으로 어디든 선언할 수 있다. 유효 범위도 지역 변수와 같다. 그런데, 지역 클래스를 쓰기 보다는 차라리 합성으로 객체를 변수로 사용하는 것이 좋지 않을까 생각한다.

 

public class EffectiveJavaTest {
    public void hello() {
        class LocalExample {
            private String name;

            public LocalExample(String name) {
                this.name = name;
            }

            public String getName() {
                return name;
            }
        }
        LocalExample localExample = new LocalExample("g-egg");
        System.out.println(localExample.getName());
    }

    public static void main(String[] args) {
        EffectiveJavaTest effectiveJavaTest = new EffectiveJavaTest();
        effectiveJavaTest.hello();
    }
}

 

"바깥 인스턴스를 참조하면, 비정적 클래스로 만들고 그렇지 않으면 정적 클래스로 만들자."

반응형
반응형

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 이 아닌 정적 멤버도 가질 수 없다.

 

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

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

 

 

 

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

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

반응형

+ Recent posts