40 @Override 애너테이션을 일관되게 사용하라

@Override는 메서드 선언에만 달 수 있으며, 이 애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의했음을 뜻합니다.
애너테이션을 일관되게 사용하면 여러 가지 악명 높은 버그들을 예방해줍니다

public class Bigram {
    private final char first;
    private final char second;

    public Bigram(char first, char second) {
        this.first = first;
        this.second = second;
    }

    public boolean equals(Bigram bigram) {
        return bigram.first == this.first &&
                bigram.second == this.second;
    }

    public int hashCode() {
        return 31 * first + second;
    }

    public static void main(String[] args) {
        Set<Bigram> s = new HashSet<>();
        for (int i = 0; i < 10; i++) {
            for (char ch = 'a'; ch <= 'z'; ch++) {
                s.add(new Bigram(ch, ch));
            }
        }

        System.out.println(s.size());
    }
}
  • Main () 메서드를 살펴보면 바이그램 26개를 10번 반복해 집합에 추가한 다음 그 집합의 크기를 출력합니다
  • Set은 중복을 허용하지 않으니 26이 출력될거 같지만 실제로는 260이 출력됩니다
    • -> 여기서 equals를 재정의한게 아니라 다중정의를 하였습니다
  • HashSet은 내부적으로 equals 메서드를 기반으로 객체의 논리적 동치적(equals) 검사를 실시합니다
  • 하지만 equals메서드의 파라미터 타입이 Bigram입니다
    • 즉, equals 메서드를 재정의 한게 아니라 Overloading 하였습니다
    • equals를 재정의 하려면 파라미터 타입이 Object이어야 합니다
@Override
public boolean equals(Object bigram) {
    if(!(o instanceof Bigram)) {
        return false;
    }
    Bigram b = (Bigram) bigram;
    return b.first == this.first &&
        b.second == this.second;
}

정리

  • 상위 클래스의 메서드를 재정의 하는 모든 메서드에 @Override 애너테이션을 다는게 좋습니다
  • 인터페이스를 상속한 구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아있다면 컴파일러가 알려줍니다
  • Java 8 부터 Default 메서드의 사용이 가능해 지면서, 인터페이스의 메서드를 재정의 할 때도 사용할 수 있습니다
  • 추상클래스나 인터페이스에서는 상위 클래스나 상위 인터페이스를 재정의하는 모든 메서드에 @Override를 다는것이 좋습니다
블로그 이미지

yhmane

댓글을 달아 주세요

38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

타입 안전 열거 패턴은 확장이 가능하나, 열거 타입은 확장을 할 수 없습니다.
하지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있습니다

타입 안전 열거 패턴(typesafe enum pattern)

enum이 나오기전 jdk 1.5 밑에 버전에서는 아래와 같이 사용하였습니다

public final class Shape {

    private String polygon;

    private Shape() {
    }

    private Shape(String polygon) {
        this.polygon = polygon;
    }

    public static final Shape TRIANGLE = new Shape("triangle");
    public static final Shape RECTANGLE = new Shape("rectangle");
    public static final Shape PENTAGON = new Shape("pentagon");
}

열거타입 (enum)

Enum class cannot inherit from classes

public enum Shape {
    TRIANGLE, RECTANGLE, PENTAGON
}
  • 열거 타입은 타입 안전 열거 패턴보다 우수합니다
  • 단, 타입 안전 열거 패턴은 확장이 가능하나 열거 타입은 확장할 수 없습니다
  • 열거 타입을 확장하려면 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하면 됩니다

인터페이스를 이용한 확장 가능 열거 타입

public interface Operation {
    double apply(double x, double y);
}
public enum BasicOperation implements Operation {

    PLUS("+") {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

    private final String symbol;

    BasicOperation(String symbol) {
        this.symbol = symbol;
    }
}

열거 타입인 BasicOperation은 확장할 수 없습니다.
다만, 인터페이스인 Operation을 이용해 확장할 수 있고
이 인터페이스를 연산의 타입으로 이용할 수 있습니다

다른 열거 타입을 추가

public enum ExtendedOperation implements Operation {

    EXP("^") {
        @Override
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        @Override
        public double apply(double x, double y) {
            return x % y;
        }
    };

    private final String symbol;

    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
}

Operation 인터페이스를 구현하면 다른 열거 타입에서도 확장할 수 있습니다


enum 타입 확인

  1. Enum타입을 넘겨 순회하기 (class literal 넘기기)
    public static void main(String[] args) {
     double x = 10;
     double y = 2;
     test(ExtendedOperation.class, x, y);
    }
    

public static <T extends Enum & Operation> void test(Class opEnumType, double x, double y) {

for (Operation op : opEnumType.getEnumConstants()) {
    System.out.printf(“%f %s %f = %f%n”, x, op, y, op.apply(x, y));
}

}


ExtendedOperation의 class 리터럴

<T extends Enum<T> & Operation> 
 타입이 Enum타입이면서, Operation을 구현하는 클래스

10 ^ 2 = 100
10 % 2 =0

2. 열거 타입의 리스트 넘기기
```java
public static void main(String[] args) {
    double x = 10;
    double y = 2;
    test(Arrays.asList(ExtendedOperation.values()), x, y);
}

public static void test(Collection<? extends Operation> opSet, double x, double y) {
    for (Operation op : opSet) {
        System.out.printf(“%f %s %f = %f%n”, x, op, y, op.apply(x, y));
    }
}

열거 타입의 리스트를 넘겨 <? extends Operation>인 한정적 와일드 카드 타입으로 지정

10 ^ 2 = 100
10 % 2 =0

정리

  • 열거 타입끼리 구현을 상속 할수는 없습니다
  • 확장할 수 있는 열거 타입이 필요한 경우 인터페이스 정의를 구현합니다
블로그 이미지

yhmane

댓글을 달아 주세요

28. 배열보다는 리스트를 사용하라

배열과 제네릭 타입에는 중요한 차이가 두가지 있습니다

1. 배열은 공변, 제네릭은 불공변입니다

// 배열, ArrayStoreException, RunTime 오류
Object[] objectArray = new Long[1];
objectArray[0] = “타입이 달라 넣을 수 없다”; 

// 리스트, 컴파일 되지 않는다
// List<Object> objectList = new ArrayList<Long>(); 

배열은 sub가 super의 하위타입이라면 sub[]는 배열 super[]의 하위타윕이 됩니다. 따라서, 위에 Long에 String을 입력하여도 컴파일 오류가 나지 않았습니다.

반면, 리스트 List은 List의 하위타입도 상위 타입도 아닙니다. 따라서, 위의 코드에서 컴파일 오류가 났습니다

2. 배열은 실체화가 되고, 리스트는 그렇지 않습니다

배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인합니다.
반면 제네릭은 타입정보가 런타임에는 소거됩니다. 이 두 차이로 인해 배열과 제네릭은 어울러질 수 없습니다

List<String>[] stringLists = new List<String>[1];

정리

타입 세이프하고, 런타임이 아닌 컴파일 단계에서 에러를 잡아줍니다. 다만, 성능으로는 배열이 유리하지만 웬만하면 리스트를 사용하도록 합니다

참조

effective java 3/e

블로그 이미지

yhmane

댓글을 달아 주세요

27. 비검사 경고를 제거하라

제네릭을 사용하기 시작하면 수많은 컴파일러 경고들을 마주치게 됩니다
이러한 경고들을 가능한 많이 제거하는 것이 습니다
경고들을 모두 제거한다면, 그 코드는 타입 안정성이 보장되기 때문입니다

대부분의 비검사 경고는 쉽게 제거할 수 있습니다

Set<Car> cars = new HashSet();
Venery.java:4: warning: [unchecked] unchecked conversion
                Set<Car> cars = new HashSet();
                                ^
  required: Set<Car>
  found:    HashSet

컴파일러가 알려준 대로 수정하면 경고가 사라집니다

자바7 이후 제공하는 다이아몬드 연산자<>로 해결이 가능합니다

Set<Car> cars = new HashSet<>();

다만, 제거하기 훨씬 어려운 경고들도 있습니다.

곧바로 해결되지 않는 경고가 나타나더라도 할 수 있는한 모든 비검사 경고를 제거하도록 합니다

경고를 제거할 수 없지만 타입이 안전하다고 확신할 수 있으면,  @SuppressWarnings(“unchecked”)를 달아 경고를 숨기도록 합니다

@SuppressWarnings

  • 지역변수 ~ 클래스 전체까지 모두 선언 할 수 있습니다
    • 가능한 가장 좁은 범위에 적용하도록 합니다
    • 또한, 클래스 전체에 적용하지 않도록 합니다
  • 한줄이 넘는 메서드나 생성자에 달린 @SuppressWarnings은 지역변수 선언 쪽으로 옮기도록 합니다
    • 이를 위해 지역변수를 새로 선언하는 수고를 해야할 수 있지만, 그만한 값어치가 있습니다
  • 또한 @ SuppressWarnings 사용시 안전한 이유를 항상 주석으로 남겨두도록 합니다 
  • public <T> T[] toArray(T[] a) { if (a.length < size) { // 생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로 올바른 형변환입니다 @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; }

참조

effective java 3/e

블로그 이미지

yhmane

댓글을 달아 주세요

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

우리가 사용하는 파일 내에 하나만 존재하는 클래스를 톱레벨 클래스라고 합니다.
(중첩클래스는 top 레벨 클래스가 아닙니다)

하지만, 소스파일 하나에 여러개의 톱레벨 클래스가 있다면 심각한 위험을 감수해야 합니다

문제

Utensil.java

class UtenSil {
    static final String NAME = "pan";
}

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

Dessert.java

class UtenSil {
    static final String NAME = "pot";
}

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

여기서 아래의 main 함수를 실행해보자

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

일반적으론 중복 클래스가 존재한다고 컴파일 에러가 나지만,
javac Main.java Utensil.java의 명령으로 컴파일 한다면 ‘pancake’가
Javac Main.java Dessert.java의 명령으로 컴파일 한다면 ‘potpie’가 출력될 것입니다

해결방안

정적멤버 클래스 또는 톱레벨 클래스를 서로 다른 파일로 분리

정적멤버클래스

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

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

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

톱레벨 클래스로 분리

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

참조

effective java 3/e

블로그 이미지

yhmane

댓글을 달아 주세요

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

중첩클래스란

중첩 클래스(nested class) 다른 클래스 안에 정의된 클래스를 말합니다.
중첩 클래스는 자신을 감싼 바깥 클래스에서만 쓰여야 하며, 그 외의 쓰임새가 있다면 톱 레벨 클래스로 만들어야 합니다

  • 정적 멤버 클래스
  • (비정적) 멤버 클래스
  • 익명 클래스
  • 지역클래스

이 중에서 정적 멤버 클래스를 제외한 나머지를 내부 클래스 (inner class)라고 합니다

정적 멤버 클래스

정적 멤버 클래스는 바깥 클래스의 private 멤버에도 접근할 수 있다는 점을 제외하고
일반 클래스와 쓰임새는 동일합니다

public class OuterWithStaticClass {

    private String name;

    static class StaticClass {
        void hello() {
            OuterWithStaticClass outerWithStaticClass = new OuterWithStaticClass();
            outerWithStaticClass.name = "홍길동";
            System.out.println(outerWithStaticClass.name);
        }
    }
}
public class Item24App {

    public static void main(String[] args) {

        OuterWithStaticClass.StaticClass staticClass = new StaticClass();
        staticClass.hello();
    }
}

비정적 멤버 클래스

구문상으로는 정적 클래스와 static이 붙어있고 없고의 차이입니다.
하지만 의미상으로 비정적 멤버 클래스는 바깥 클래스의 인스턴스와 암묵적으로 연결됩니다. 따라서 정규화된 this를 사용해 바깥 인스턴스의 메서드를 호출하거나 참조를 가져올 수 있습니다

public class OuterWithNoneStaticClass {

    private final String name;

    public OuterWithNoneStaticClass(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 + OuterWithNoneStaticClass.this.name;
        }
    }
}
public class Item24App {

    public static void main(String[] args) {
        OuterWithNoneStaticClass noneStaticClass = new OuterWithNoneStaticClass("고길동");
        System.out.println(noneStaticClass.getName());
    }
}

드물게 직접 바깥 인스턴스의 클래스.new Member Class(args)를 호출해 수동으로
만들기도 합니다. 하지만 이 관계 정보는 비정적 멤버 클래스의 인스턴스 안에 만들어져 메모리 공간을 차지하며, 생성시간도 더 걸립니다.

따라서, 중첩 클래스의 인스턴스가 바깥 클래스의 인스턴스와 독립적으로 존재해야 한다면 정적 멤버 클래스로 만드는게 좋습니다.

익명 클래스

  • 이름이 없고 바깥 클래스의 멤버도 아닙니다
  • 쓰이는 시점에 선언과 동시에 인스턴스가 만들어집니다
  • 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스 참조가 가능하다
  • 상수 정적변수 (static final) 외에는 정적 변수를 가질 수 없다.
public class Anonymouss {

    private String name;

    public void hello() {
        HelloBot helloBot = new HelloBot() {
            @Override
            public void hello() {
                System.out.println("안녕하세요. 헬로우 봇입니다");
            }
        };
        helloBot.hello();
    }
}

interface HelloBot {
    void hello();
}
public class Item24App {

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

익명 클래스의 제약사항

  • 선언한 지점에서만 인스턴스를 만들수 있습니다
  • 여러 인터페이스를 구현할 수 없고, 구현과 동시에 다른클래스 상속도 불가능합니다
  • 익명 클래스 사용 클라이언트는 사용하는 익명 클래스가 상위타입에서 상속한 멤버외에는 호출이 불가능합니다
  • 람다(자바7) 등장 이전에는 작은 함수 객체나 처리 객체 구현에 사용되었지만 java8 부터는 람다를 제공합니다

지역 클래스

네 가지 중첩 클래스 중 가장 드물게 사용됩니다.
지역 클래스는 지역 변수를 선언할 수 있는 곳이면 어디서든 선언할 수 있고, 유효 범위도 지역 변수와 같습니다.

public class LocalClass {

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

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

            public String getName() {
                return name;
            }
        }

        LocalExample localExample = new LocalExample("윤호호");
        System.out.println(localExample.getName());
    }
}
public class Item24App {

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

지역 클래스는 다른 중첩 클래스들의 공통점을 하나씩 가지고 있다.

  • 멤버 클래스처럼 이름을 가질수 있고 반복해서 사용할 수 있습니다
  • 비정적 문맥에서 사용될 때만 바깥 인스턴스를 참조할 수 있습니다
  • 정적 멤버는 가질 수 없으며, 가독성을 위해 짧게 작성되어야 합니다
블로그 이미지

yhmane

댓글을 달아 주세요

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

태그 달린 클래스

태그 클래스란, 두가지 이상의 의미를 표현할 때 그 중 현재 표현하는 의미를 태그값으로 알려주는 클래스 입니다.

책에 나온 예제로 살펴보도록 하겠습니다.

class Figure {

    enum Shape {
        RECTANGLE, CIRCLE
    }

    // 태그 필드
    final Shape shape;

    // RECTANGLE 용 필드
    double length;
    double width;

    // CIRCLE 용 필드
    double radius;

    // RECTANGLE
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }

    // CIRCLE
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    double area() {
        switch(shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

태그 클래스의 단점

  1. Enum switch, field 등 여러 구현이 혼합 되어 있습니다
  2. 이로 인해 가독성이 떨어집니다
  3. 사용하지 않는 필드로 인해 메모리도 추가적으로 사용합니다
  4. 또 다른 타입을 추가하게 되면 switch 문의 수정이 일어납니다
  5. 인스턴스 타입만으로 객체의 의미를 알 수 없습니다

태그가 달린 클래스는 코드가 길어지고 확장성에 취약합니다.
또한, 태그가 추가 될 때마다 사용하지 않는 필드들이 추가 될 수도 있습니다.


계층 클래스

계층 구조의 클래스를 만드는 방법

  1. 계층구조의 최상위(root)가 될 추상클래스를 정의합니다
  2. 태그값에 따라 달라지는 동작(메서드)들을 최상위 클래스의 추상 메서드로 선언합니다
  3. 태그값에 상관없이 동작이 일정한 메서드는 최상위 클래스에 일반 메서드로 정의합니다.
  4. 모든 하위 클래스에 공통으로 사용하는 상태값(필드)들은 루크 클래스에 정의합니다.

계층구조로 변환

abstract class Figure {
    abstract double area();
}

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;
    }
}


class Circle extends Figure {

    final double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * (radius * radius);
    }
}

클래스 계층구조의 장점

  1. 독립된 의미를 가지는 상태값(필드)들이 제거 되어 각 구현 클래스는 간결해집니다
  2. 살아남은 field는 모두 final이므로 정의할 수 있습니다
  3. 실수로 빼먹은 case 문으로 인해 runTime 에러를 방지할 수 있습니다
  4. 최상위 클래스를 수정하지 않고 타입을 확장 할 수 있습니다
  5. 타입사이의 자연스러운 계층 관계를 반영할 수 있습니다
블로그 이미지

yhmane

댓글을 달아 주세요

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

인터페이스는 타입을 정의하는 용도로만 사용해야 합니다.
상수 공개용 수단으로 사용해서는 안됩니다

안티패턴 - 상수 인터페이스

메서드 없이, 상수를 뜻하는 static final 필드로만 가득찬 인터페이스

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;
}
  • 클래스 내부에서 사용하는 상수는 외부 인터페이스가 아닌 내부 구현입니다
    • 내부 구현을 클래스의 API로 노출하는 행위가 됩니다
    • 또한, 사용자에게 혼란과 오해의 소지를 줄 뿐입니다

대안

클래스의 상수로 선언하여 사용합니다

public class PhysicalConstants {

    // 인스턴스화 방지
    private 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;
}

또는 enum의 값으로 사용합니다

public enum PhysicalConstants {

    AVOGADROS_NUMBER(6.002_140_857e23),
    BOLTZMANN_CONSTANT(1.380_648_52e-23),
    ELECTRON_MASS(9.109_383_56e-31);

    private double value;

    PhysicalConstants(double value) {
        this.value = value;
    }

    public double getValue(){
        return value;
    }
}

참조

effective java 3/e - 조슈아 블로크

블로그 이미지

yhmane

댓글을 달아 주세요

14장. 일관성 있는 협력


객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다.
하지만 재사용은 꽁짜로 얻어지지 않는다.
재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다

아래 예제를 통해 일관성 있는 협력 패턴이, 이해하기 쉽고 직관적이라는 것을 알아보자


01. 핸드폰 과금 시스템 변경하기

기본 정책 확장

11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 아래와 같이 수정해보자

  • 고정요금 방식
    • ex) 10초당 18원
  • 시간대별 방식
    • ex) 00 ~ 19시 10초당 18원
    • ex) 19 ~ 24시 10초당 15원
  • 요일별 방식
    • ex) 평일 10초당 38원
    • ex) 공휴일 10초당 19원
  • 구간별 방식
    • ex) 초기 1분 10초당 50원
    • ex) 1분 이후 10초당 20원

조합 가능한 요금 계산 순서


위의 사진처럼 무수히 많은 조합이 나오기에 설계가 중요해졌다.

클래스 구조

고정요금 방식 구현하기

가장 간단한 고정요금이다. 일반요금제와 동일하다.

public class FixedFeePolicy extends BasicRatePolicy {
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 계산하기 위해서는 통화 기간을 정해진 시간대별로 나눈후 시간대별로 서로 다른 계산 규칙을 적용해야 한다.
시간대별 방식의 통화 요금일 계산하기 위해서는 통화의 시작, 종료시간, 시작일자, 종료일자도 함께 고려되어야 한다.

시간대별 통화 시간을 관리하는 클래스 DateTimeInterval

public class DateTimeInterval {
    public static DateTimeInterval of(LocalDateTime from, LocalDateTime to) {
        return new DateTimeInterval(from, to);
    }

    public static DateTimeInterval toMidnight(LocalDateTime from) {
        return new DateTimeInterval(from, LocalDateTime.of(from.toLocalDate(), LocalTime.of(23, 59, 59, 999_999_999)));
    }

    public static DateTimeInterval fromMidnight(LocalDateTime to) {
        return new DateTimeInterval(LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)), to);
    }

    public static DateTimeInterval during(LocalDate date) {
        return new DateTimeInterval(
                LocalDateTime.of(date, LocalTime.of(0, 0)),
                LocalDateTime.of(date, LocalTime.of(23, 59, 59, 999_999_999)));
    }

    private DateTimeInterval(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }

    public Duration duration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }

    public LocalDateTime getTo() {
        return to;
    }

    public List<DateTimeInterval> splitByDay() {
        if (days() > 0) {
            return split(days());
        }
        return Arrays.asList(this);
    }

    private long days() {
        return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
    }

    private List<DateTimeInterval> split(long days) {
        List<DateTimeInterval> result = new ArrayList<>();
        addFirstDay(result);
        addMiddleDays(result, days);
        addLastDay(result);
        return result;
    }

    private void addFirstDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.toMidnight(from));
    }

    private void addMiddleDays(List<DateTimeInterval> result, long days) {
        for(int loop=1; loop < days; loop++) {
result.add(DateTimeInterval.during(from.toLocalDate().plusDays(loop)));
        }
    }

    private void addLastDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.fromMidnight(to));
    }

    public String toString() {
        return "[ " + from + " - " + to + " ]";
    }
}

요일별 방식 구현하기

요일별 방식은 요일별로 요금 규칙을 다르세 설정할 수 있다.
각 규칙은 요일의 목록, 단위 시간, 단위 요금이라는 3가지 요소로 구성된다.

public class DayOfWeekDiscountRule {
    public Money calculate(DateTimeInterval interval) {
        if (dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
            return amount.times(interval.duration().getSeconds() / duration.getSeconds());
        }
        return Money.ZERO;
    }
}

요일별 방식 역시 통화 기간이 여러 날에 걸쳐 있을 수 있다.
시간대별 방식과 동일하게 통화 기간을 날짜 경계로 분리하고 각 통화 기간을 요일별로 설정된 요금 정책에 따라 적절하게 계산해야 한다.

public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.getInterval().splitByDay()) {
            for(DayOfWeekDiscountRule rule: rules) { result.plus(rule.calculate(interval));
            }
        }
        return result;
    }
}

잠시 지금까지 구현한 고정요금, 시간대별, 요일별 방식의 클래스를 다시 살펴보자.
겉으로 보기에는 문제가 없이 잘 구현된 것 같아 보인다. FixedFeePolicy, TimeOfDayDiscountPolicy, DayOfWeekDiscountPolicy
세 클래스는 통화 요금을 정확하게 계산하고 있고 응집도와 결합도 측면에서도 특별히 문제는 없어 보인다.
그러나 이 클래스들을 함께 모아놓고 보면 문제점이 보인다

문제는 이 클래스들이 유사한 문제를 해결하고 있음에도 설계의 일관성이 없다는 것이다


02. 설계에 일관성 부여하기

설계에 일관성을 부여하기 위해서는 다음과 같은 연습이 필요하다

  • 다양한 설계 경험 익히기
  • 디자인 패턴을 학습하고 변경이라는 문맥안에 적용해보기
  • 협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르기
    • 변하는 개념을 변하지 않는 개념으로부터 분리
    • 변하는 개념을 캡슐화

조건 로직 대 객체 탐색

먼저, 조건 로직에 대해서 코드를 통해서 살펴보자.
4장에서 나왔던 ReservationAgency 예제이다.

public class ReservationAgency {
    public Reservation reserve(...) {
        for (DiscountCondition condition : movie,getDiscountConditions()) {
            if (condition.getType() == DiscountCondtionType.PERIDOD) {
                // 기간조건
            } else {
                // 회차조건
            }
        }

        if (discountable) {
            switch(movie.getMovieType)) {
                case AMOUNT_DISCOUNT: // 금액 할인 정책
                case PERCENT_DISCOUNT: // 비율 할인 정책
                case NONE_DISCOUNT: // 할인 정책 없음
            }
        } else {
            // 할인 정책이 불가능한 경우
        }
    }
}

위와 같이 조건 로직으로 구현한다면 변경에 취약하고 유지보수의 어려움성이 생긴다.
조건로직이란 위와 같이 'if ~ else' 를 통해 비즈니스 로직에 덕지덕지 붙여 놓은 것이다.

우리는 추상화와 다형성이라는 것을 배웠기에 인터페이스를 이용해 객체 탐색으로 변경할 수 있다.

public class Movie {
    private DiscountPolicy discountPolicy;
    public Money calculteMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

이처럼 다형성은 조건 로직을 객체 사이의 이동으로 바꾸어 준다.
따라서, 할인 조건에 맞는 메시지만 주고 받으면 되기 때문에 변경이 용이해진다


03. 일관성 있는 기본 정책 구현하기

변경 분리하기

일관성 있는 협력을 만들기 위한 첫 단계는 변하는 개념과 변치 않는 개념을 분리하는 것이다
앞에서 본 핸드폰 과금 시스템의 기본 정책에서 변하는 부분과 변하지 않는 부분을 다시 살펴보자


단위요금이 시간당 요금을 계산하는 반면, 적용조건은 형식이 다르다른 것을 알 수 있다.
이것으로 적용조건은 변하는 부분이고, 단위요금은 변치 않는 다는 것으로 분리할 수 있다는 것을 유추할 수 있다.

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.
여기서 변하지 않는 것은 ‘규칙’이다. 변하는 것은 ‘적용조건’이다.
따라서 규칙으로부터 적용조건을 분리하여 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화의 서브타입으로 만들어야 한다.
그 후에 규칙이 적용조건을 표현하는 추상화를 합성관계로 연결하는 것이 객체의 캡슐화이다.

추상화 수준에서 협력 패턴 구현하기

먼저, ‘적용조건’을 표현하는 추상화인 FeeCondtion

public interface FeeCondition {
    List<DateTimeInterval> findTimeIntervals(Call call);
}

다음으로 FeeFule, 단위요금과 적용조건을 인스턴스변수로 사용한다

public class FeeRule {
    private FeeCondition feeCondition;
    private FeePerDuration feePerDuration;

    public Money calculateFee(Call call) {
        return feeCondition.findTimeIntervals(call)
                .stream()
                .map(each -> feePerDuration.calculate(each))
                .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }
}

FeePerDuartion클래스는 단위 시간당 요금이라는 개념을 표현한다.
또한, 이정보를 이용해 일정기간의 요금을 계산하는 calculate 메서드를 구현한다

public class FeePerDuration {
    private Money fee;
    private Duration duration;

    public Money calculate(DateTimeInterval interval) {
        return fee.times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
    }
}

구체적인 협력 구현하기

이제 맨 처음 앞서 구현한 구체적인 협력 클래스를 인터페이스를 이용해 구현해보자
TimeOfDayFeeCondition 시간대별 정책

public class TimeOfDayFeeCondition implements FeeCondition {
    private LocalTime from;
    private LocalTime to;

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval().splitByDay()
                .stream()
                .filter(each -> from(each).isBefore(to(each)))
                .map(each -> DateTimeInterval.of(LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
              LocalDateTime.of(each.getTo().toLocalDate(), to(each))))
                .collect(Collectors.toList());
    }

    private LocalTime from(DateTimeInterval interval) {
        return interval.getFrom().toLocalTime().isBefore(from) ?
                from : interval.getFrom().toLocalTime();
    }

    private LocalTime to(DateTimeInterval interval) {
        return interval.getTo().toLocalTime().isAfter(to) ?
                to : interval.getTo().toLocalTime();
    }
}

DayOfWeekFeeCondition, 요일별정책

public class DayOfWeekFeeCondition implements FeeCondition {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval()
                .splitByDay()
                .stream()
                .filter(each ->
 dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                .collect(Collectors.toList());
    }
}

DurationFeeCondition 구간별정책

public class DurationFeeCondition implements FeeCondition {
    private Duration from;
    private Duration to;

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        if (call.getInterval().duration().compareTo(from) < 0) {
            return Collections.emptyList();
        }

        return Arrays.asList(DateTimeInterval.of(
                call.getInterval().getFrom().plus(from),
                call.getInterval().duration().compareTo(to) > 0 ?
                       call.getInterval().getFrom().plus(to) :
                        call.getInterval().getTo()));
    }
}

맨처음 짯던 정책들과 비교를 해보자. 각 클래스마다 일관성이 생기게 되었다.
우리는 변하는 개념과 변치 않는 개념을 분리해서 변경을 캡슐화 하였다.
이처럼 변경을 캡슐화해 협력을 일관성 있게 만들면 어떤 장점을 얻을 수 있는지 명확하게 보여준다


마찬가지로, 추가적인 정책이 필요하다면 FeeCondition으로부터 구현을 해주면 된다.

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
변경을 캡슐화 하는 방법이 협력에 참여하는 객체들의 역할과 책임을 결정하고 이렇게 결정된 협력이 코드의 구조를 결정한다.
따라서, 변경의 방향을 파악할 수 있는 감각을 기르는 것이 중요하다.


참조

  • 조영호님의 오브젝트 '일관성 있는 협력'

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

yhmane

댓글을 달아 주세요

[Object] 다형성

Books/Object 2021. 8. 12. 00:48

12장 다형성


01. 다형성

다형성(Polymorphism)
‘많은’ - poly
‘형태’ - ‘morph’의 합성어로 많은 형태를 가질 수 있는 능력을 의미

‘Computer Science’ 에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고,
이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다.

'객체지향 프로그래밍'에서는 다형성을 4개로 분류하여 정의한다.

  • 오버로딩 다형성
    • 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우
  • 강제 다형성
    • 자동적인 타입 변환 방식
  • 매개변수 다형성
    • 제네릭 프로그맹과 관련이 깊다
  • 포함 다형성
    • 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제 수행되는 행동이 달라진다
    • 서브타입 다형성이라고도 부른다

02. 상속의 양면성

객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두가지 관점을 함께 고려해야 한다.
상속의 경우도 마찬가지이다.

  • 부모 클래스에 정의한 모든 데이터를 자식 클래스의 인스터에 포함시킬 수 있다 -> 데이터 관점의 상속
  • 데이터 뿐만 아니라 메서드도 포함 시킬 수 있다 -> 행동 관점의 상속

상속은 부모 클래스에서 정의한 것을 자동적으로
공유하고 재사용 할 수 있는 메커니즘으로 보이지만,
이 관점은 상속을 오해한 것

  • 상속의 목적은 코드 재사용이 아니다
  • 상속은 프로그램을 구성하는 개념들 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것
  • 타입 계층에 대한 고민 없이 상속을 이용하면 유지보수가 어려워 진다

상속을 사용한 강의 평가

상속의 메카니즘을 이해하기 위해 예제를 살펴보자. 수강생들의 성적을 계산하는 프로그램

Lecture.java

public class Lecture {
    private int pass; // 이수여부 판단할 기준 점수
    private String title; // 과목명
    private List<Integer> scores = new ArrayList<>(); // 학생들의 성적 리스트

    public Lecture(String title, int pass, List<Integer> scores) {
        this.title = title;
        this.pass = pass;
        this.scores = scores;
    }

    public double average() {
        return scores.stream().mapToInt(Integer::intValue).average().orElse(0);
    }

    public List<Integer> getScores() {
        return Collections.unmodifiableList(scores);
    }

    public String evaluate() {
        return String.format("Pass:%d Fail:%d", passCount(), failCount());
    }

    private long passCount() {
        return scores.stream().filter(score -> score >= pass).count();
    }

    private long failCount() {
        return scores.size() - passCount();
    }
}

이수 기준 70점, 5명의 대한 설정 통계

Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50,45));
String evaluration = lecture.evaluate(); // 결과 => "Pass:3, Fail:2"


다음으로 좀더 기능을 확장해보자. Lecture의 출력 결과에 등급별 통계를 추가

GradeLecture.java

public class GradeLecture extends Lecture {
    private List<Grade> grades;

    public GradeLecture(String name, int pass, List<Grade> grades, List<Integer> scores) {
        super(name, pass, scores);
        this.grades = grades;
    }

    @Override
    public String evaluate() {
        return super.evaluate() + ", " + gradesStatistics();
    }

    private String gradesStatistics() {
        return grades.stream().map(grade -> format(grade)).collect(joining(" "));
    }

    private String format(Grade grade) {
        return String.format("%s:%d", grade.getName(), gradeCount(grade));
    }

    private long gradeCount(Grade grade) {
        return getScores().stream().filter(grade::include).count();
    }

    public double average(String gradeName) {
        return grades.stream()
                .filter(each -> each.isName(gradeName))
                .findFirst()
                .map(this::gradeAverage)
                .orElse(0d);
    }

    private double gradeAverage(Grade grade) {
        return getScores().stream()
                .filter(grade::include)
                .mapToInt(Integer::intValue)
                .average()
                .orElse(0);
    }
}


등급의 이름과 각등긥 범위를 정의

Grade.java

public class Grade {
    private String name; // 등급
    private int upper,lower; // 상한선, 하한선

    private Grade(String name, int upper, int lower) {
        this.name = name;
        this.upper = upper;
        this.lower = lower;
    }

    public String getName() {
        return name;
    }

    public boolean isName(String name) {
        return this.name.equals(name);
    }

    public boolean include(int score) {
        return score >= lower && score <= upper;
    }
}

GradeLecture 클래스의 evaluate 메서드를 살펴 보자. 부모의 evaluate를 재정의하여 사용하고 있다.
이처럼 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것을 메서드 오버라이딩이라 한다

Lecture와 GradeLecture의 average를 살펴보자. 메서드 이름은 같지만 시그니처가 다르다.
둘은 대체되지 않으며 호출하는 방법이 다르므로 공존하게 된다. 이처럼 메서드 이름은 동일하지만 시그니처는 다른 메서드를 메서드 오버로딩이라 한다

데이터 관점의 상속

위에서 구현한 클래스를 인스턴스로 생성해보자

Lecture lecture = new Lecture("객체지향 프로그래밍", 70, Arrays.asList(81, 95, 75, 50,45));
Lecture lecture = new GradeLecture("객체지향 프로그래밍",
    70, Arrays.asList(    new Grade("A", 100, 95),
                        new Grade("B", 94, 80),
                        new Grade("C", 79, 70),
                        new Grade("D", 69, 50),
                        new Grade("F", 49, 0)),
        Arrays.asList(81, 95, 75, 50, 45));

아래 사진과 같이 데이의 관점에서 표현할 수 있다


즉, 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 된다

행동 관점의 상속

행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다. 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 인터페이스에 포함된다.


03. 업캐스팅과 동적 바인딩

같은 메시지, 다른 메서드

성적 계산 프로그램에 각 교수별로 강의에 대한 성적 통계 기능을 추가해 본다

Professor.java 통계를 계산

public class Professor {
    private String name;
    private Lecture lecture;

    public Professor(String name, Lecture lecture) {
        this.name = name;
        this.lecture = lecture;
    }

    public String compileStatistics() {
        return String.format("[%s] %s - Avg: %.1f", name,
                lecture.evaluate(), lecture.average());
    }
}
Professore professor = new Profeesor("다익스트라", new Lecture(...));
Professore professor = new Profeesor("다익스트라", new GradeLecture(...));

Professor의 생성자 인자 타입은 Lecture로 선언돼 있지만 Lecture, GradeLecture 모두 올수가 있다.
코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라
실행되는 메서드가 달라지는 것은 업캐스팅과 동적 바인딩 메카니즘이 작용하기 때문이다

  • 부모 클래스 타입으로 선언된 변수에서 자식 클래스의 인스턴스를 할당하는 것을 업캐스팅이라 한다
  • 선언된 변수 타입이 아니라도 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 컴파일 시점이 아니라 실행시점에 결정하기 때문인데, 이를 동적 바인딩이라 한다

업캐스팅

상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼빌릭 인터페이스에 합쳐지기 때문에 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다.

Lecture lecture = new GradeLecture(...);

반대의 경우를 다운캐스팅(downcasting)이라 한다

Lecture lecture = new GradeLecture(...);
GradeLecture gradeLecture = (GradeLecture) lecture;

동적 바인딩

컴파일 시점이 아닌 메서드를 런타임에 결정하는 방식을 동적바인딩, 지연바인딩 이라 한다. (앞 chapter에 나와 있는 추상화와 의존성을 같이 봐두면 좋다)


04. 동적 메서드 탐색과 다형성

객체지향 시스템은 다음과 같은 규칙으로 실행할 메서드를 선택한다

  1. 먼저, 자신을 생성한 클래스에 적합한 메서드가 있는지 검사
  2. 없을 경우 부모 클래스에서 메스드를 탐색, 있을때까지 상속 계층을 따라 올라간다
  3. 최상위 계층까지 탐색 하여 없을 경우 예외 발생

자동적인 메시지 위임

상속을 이용할 경우, 메시지 수신을 처리할 대상을 자동으로 찾게 된다.
즉, 명시적으로 코드를 작성할 필요가 없고, 상속 계층에 따라 부모 클래스에게 위임 된다.
이런 관점에서 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다.

메서드 오버라이딩

Lecture lecture = new GradeLecture(...);
lecture.evalute();

위의 코드를 실행해보자. Lecture.evaluate -> 는 GradeLecture.evaluate에 재정의 되어 있다.
실행시, self 참조에 의해 자기 자신의 클래스를 먼저 탐색하게 된다. 따라서, GradeLecture.evaluate()가 실행되게 되고, 오버라이딩된 부모 클래스의 메서드를 감추게 한다.

동적메서드 탐색이 자식 클래스에서 부모 클래스 방향으로 진행된다는 것을 기억하면 오버라이딩의 결과는 당연하다.

메서드 오버로딩

Lecture lecture = new GradeLecture(...);
lecture.avaerage();

위의 코드를 실행해보자. 오버라이딩과 마찬가지로 selft 참조를 하게 된다.
GradeLecture에는 시그니처가 다르기에 부모 클래스에서 해당 시그니처를 찾게 된다.

이처럼 시그니처가 다르기 때문에 동일한 이름의 메서드가 공존하는 경우를 오버로딩이라 부른다.

동적인 문맥

lecture.evaluate()

위의 메시지 전송만으로 어떤 클래스의 메서드가 실행될지 알 수 없다는 것을 이해하였다. 여기서 중요한 것은 메시지를 수신한 객체가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 동적으로 바뀐다는 것이다. 그리고 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조이다

이해할 수 없는 메시지

동적 메서드 탐색을 통해, 메시지를 수신하면 부모 클래스까지 탐색하여 찾게된다. 만약, 최상위 계층까지 탐색후 원하는 메시지가 없다면 어떻게 될까?

정적 타입 언어와 이해할 수 없는 메시지

정적 타입 언어에서는 상속 계층 전체를 탐색후 메시지를 처리할 메서드를 발견하지 못하면 컴파일 에러를 발생시킨다

Lecture lecture = new GradeLecture(...);
lecture.unknownMessage(); // 컴파일 에러!!

동적 타입 언어와 이해할 수 없는 메시지

동적 타입 언어도 자식으로 부터 부모 방향으로 메서드를 탐색한다. 차이점이라면 컴파일단계가 존재하지 않아 실제 코드를 실행해보기 전엔 메시지 처리 가능 여부를 알 수 없다는 점이다.

super

Self 참조의 가장 큰 특징은 동적이라는 점이다. Self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 super 참조이다.

자식 클래스에서 부모 클래스의 구현을 재사용해야 하는 경우가 있다. 이럴 경우, 부모 클래스의 변수나 메서드에 접근하기 위해 super 참조라는 내부 변수를 제공한다.

public class GradeLecture extends Lecture {
    @Override
    public String evaluate() {
        return super.evalute() + ", " + gradesStatistics();
    }
}

또한, super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공한다.
그 메서드가 조상 클래스 어딘가에 있기만 하면 성공적으로 탐색이 가능하다


참조

  • 조영호님의 오브젝트 12장 “다형성”

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

yhmane

댓글을 달아 주세요