요약

정수 상수 대신 열거 타입을 사용하자. 열거 타입이 더 가독성 좋고, 안전하다.

필요한 원소를 컴파일 타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.

 

-> 열거 타입에서 메서드 구현 방식으로는 상수별 메서드 구현, 전략 열거 타입 패턴이 있다.

 

열거 타입

열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다. 예시로 사계절, 태양계의 행성, 카드 게임의 카드 종류 등이 있다.

 

정수 열거 패턴은 되도록이면 사용하지 말자.

왜냐하면 타입 안전을 보장할 수 있는 방법이 없고, 표현력도 좋지 않고, 코드가 깨지기 쉽기 때문이다.

public static final int APPLE_FUJI = 0;
public static final int ORANGE_NAVEL = 0;
  • 타입 안전 보장 못함
    • APPLE_을 사용하고 싶은데 실수로 ORANGE_를 사용하더라도 잘못 입력했단 걸 미리 확인할 수 없음
  • 표현력 좋지 않음
    • 이름으로 상수를 구분하면 값 출력이나 디버깅 시 숫자만 표현되어 해당 숫자가 어떤 것을 의미하는지 파악하기 어렵다. 위 코드를 예시로 0으로 확인되는 것의 값이 APPLE인지 ORANGE인지 알 수 있는 길이 없다
  • 코드 깨지기 쉬움
    • 상수 값 변경 시 관련 클라이언트도 재컴파일 해야하는 번거로움이 생길 수 있다.

 

열거 타입 사용하자.

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • 열거 타입 특징
    • 자바 열거 타입 자체는 클래스
    • 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 설정
    • 열거 타입은 public 생성자를 제공하지 않음
    • 열거 타입으로 만들어진 인스턴스는 하나만 존재 → 인스턴스가 통제됨
      • 열거타입은 싱글턴을 일반화한 형태
      • 싱글턴은 원소가 하나뿐인 열거 타입
  • 열거 타입 장점
    • 컴파일타임 타입 안정성 제공
      • ex) Apple 타입이면 3가지 중 하나임을 보장, 아니면 컴파일 오류 발생
    • 새로운 상수를 추가하거나 순서를 바꿔도 재컴파일 하지 않아도 됨
    • 열거 타입 각자 이름공간이 있어 이름이 같은 상수도 평화롭게 공존 가능
    → 정수 열거 패턴의 단점을 해소 해준다.

 

데이터와 메서드를 갖는 열거 타입

각 상수와 연관된 데이터를 해당 상수 자체에 내재시키고 싶을 때 인스턴스 필드와 메서드를 이용하면 된다. 필드와 메서드의 접근 제어자는 필요한 경우가 아니라면 웬만해선 private, package-private(default)로 선언해야 한다.

public enum Planet {
    EARTH(123, 456);

    private final double mass;
    private final double radius;
    private final double surfaceGravity;

    private static final double G = 1;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
    }
    
    public double mass() { return mass; }
    public double radius() { return radius; }
    public double surfaceGravity() { return surfaceGravity; }

    public double surfaceWeight(double mass) { return mass * surfaceGravity; }
}

EARTH와 관련된 mass, radius 값은 변하지 않으므로 열거 타입에 상수로 저장해 놓으면 물체 무게(mass)만 입력하면 지구에서의 물체 무게를 바로 구할 수 있다.

for (Planet p : Planet.values()) {
    System.out.printf("%s에서의 무게는 %f이다.", p, p.surfaceWeight(10));
}

열거 타입을 이용하면 행성 관련 정보를 입력하지 않고 짧은 코드로 원하는 값을 출력할 수 있다. 또한 열거 타입에서 상수가 새로 추가되거나 제거되는 변경이 발생하더라도 컴파일 오류가 발생하기 때문에 안전하게 대응할 수 있다.

 

상수별 메서드 구현

상수별 메서드 구현은 열거 타입에서 apply라는 추상 메서드를 선언하고, 각 상수에 맞게 재정의하는 방법을 의미한다.

  • 상수별 메서드 구현을 권장하는 경우
    • 상수를 특정 데이터와 연관지어야 하는 경우
    • 상수별로 메서드를 실행해야 하는 경우
public enum Operation {
    PLUS  {public double apply(double x, double y) {return x + y;},
    MINUS {public double apply(double x, double y) {return x - y;},
    
    public abstract double apply(double x, double y);
}

위 코드처럼 작성하면 새로운 상수를 추가할 때 재정의 메서드를 빼먹지 않고 추가할 수 있을 것이다. 그래도 만약 재정이 메서드를 작성하지 않았다면 컴파일 오류로 알려주기 때문에 안전성을 보장한다.

  • 장점
    • 재정의 메서드 빼먹지 않을 수 있음
      • 상수 옆에 바로 재정의 메서드가 있어서 바로 확인이 가능하다
    • 컴파일 오류
      • 만약 메서드를 작성하지 않는다면 컴파일 오류로 타입 안정성을 보장할 수 있다
  • 단점
    • 열거 타입 상수끼리 코드 공유가 어렵다

 

근무시간 계산 - 열거 타입 상수끼리 코드 공유 어려운 경우

enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY

    private static final int MINS_PER_SHIFT = 8 * 60;
    
    int pay(int minutesWorked, int payRate) {
        int basePay = minuteWorked * payRate;

        int overtimePay;
        switch (this) {
            case SATURDAY: case SUNDAY:
                overtimePay = basePay / 2;
                break;
            default :
                overtimePay = minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }
        
        return basePay + overtiePay;
}

열거 타입으로 비교적 간단한 코드이지만 이 코드에는 관리 관점에서 문제가 있다.

HOLIDAY 같은 새로운 값을 열거 타입에 추가하려면 HOLIDAY를 추가하고, 관련 로직을 switch()에 따로 추가해줘야 한다. 그렇지 않으면 휴일 비율이 아닌 평일 비율로 계산될 수 있기 때문이다.

이 문제를 해결하기 위해서는 전략 열거 타입 패턴을 이용해 코드를 작성하면 된다.

 

전략 열거 타입 패턴

  • 전략 열거 타입 패턴을 권장하는 경우
    • 열거 타입 상수 일부가 같은 동작을 공유하는 경우
enum PayrollDay {
    MONDAY(WEEKDAY), SATURDAY(WEEKEND), THANKSGIVINGDAY(HOLIDAY);

    private final PayType payType;
    
    PayrollDay(PayType payType) { this.payType = payType; }

    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate)
    }

   // 전략 열거 타입
    enum PayType {
        WEEKDAY {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };

        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;

        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}
  1. 잔업 수당 계산은 private 중첩 열거 타입인 PayType을 이용한다.
  2. PayrollDay 생성자에서 PayType을 선택해 관련 로직을 진행한다.

→ 이렇게 할 경우 코드는 좀 복잡해질 수 있지만, 더 안전하고 유연하게 코드를 작성할 수 있다.

요약

이왕이면 형변환하는 기존 메서드는 제네릭 메서드로 바꾸자. 안정성과 사용성 면에서 더 낫다.

클라이언트에서 형변환하는 메서드는 되도록이면 만들지 말자.

 

제네릭 메서드란?

타입 매개변수를 사용하는 메서드를 의미한다.

제네릭 메서드 작성 방법

메서드 선언에서의 원소 타입을 타입 매개변수(E)로 명시하고, 메서드 안에서도 이 타입 매개변수만 사용하게 하면 된다.

// [접근제어자] static [타입 매개변수 목록] [반환타입] [함수명](파라미터)
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
    Set<E> result = new HashSet<>(s1);
    result.addAll(s2);
    return result;
}
  • 특징
    • 타입 매개변수 목록 : <E>
    • 반환 타입 : Set<E>
    • 파라미터 2개, 반환 객체 1개 모두 같은 타입이어야 한다.
    → 컴파일 에러 x, 타입 안전, 사용하기 쉬움

 

제네릭 싱글턴 팩터리

요청한 타입 매개변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩터리를 의미한다.

(기존 타입) → (요청한 타입 매개변수)

  • 예시 : Collections.reverseOrder, Collections.emptySet
@SuppressWarnings("unchecked")
public static <T> Comparator<T> reverseOrder() {
     return (Comparator<T>) ReverseComparator.REVERSE_ORDER;
}

@SuppressWarnings("unchecked")
public static final <T> Set<T> emptySet() {
    return (Set<T>) EMPTY_SET;
}

 

 

예시 : 항등함수

항등함수는 입력 값을 수정 없이 그대로 반환하는 특별한 함수를 의미한다.

private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;

@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
    return (UnaryOperator<T>) IDENTITY_FN;
}
  • 필드 IDENTITY_FN의 타입 = UnaryOperator<Object>
  • identityFunction()의 반환 타입 = UnaryOperator<T>이다.
  • 따라서 UnaryOperator<Object> ≠ UnaryOperator<T> 이기에 비검사 형변환 경고가 발생
    • 이때 항등함수는 입력 값을 수정 없이 그대로 반환하는 함수이므로, 타입 안전하다.
    • 위에서 발생한 비검사 형변환 경고는 숨겨도 안전하기에 @SuppressWarning(”unchecked”) 설정

 

재귀적 타입 한정(recursive type bound)

자기 자신이 들어간 표현식(<E extends Comparable<E>>)을 활용해 타입 매개변수의 허용 범위를 한정하는 개념이다. 재귀적 타입 한정은 주로 타입의 자연적 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.

public interface Comparable<T> {
    int compareTo(T o);
}

new Comparable<String> -> String 타입만 사용가능
  • compareTo()의 매개변수 T는 Comparable<T>를 구현한 타입이 비교할 수 있는 원소 타입을 정의한다 → 한 타입만 사용하도록 강제시키기 때문에 타입 안정성 보장
  • String은 Comparable<String>을 구현, Integer는 Comparable<Integer>를 구현하는 식
// 파라미터 타입 Collection<E>, 타입 파라미터 목록이 <E extends Comparable<E>>이므로 
// 파라미터에 E를 포함한 자식 타입만 입력될 수 있다는 의미?
// 컬렉션에서 최댓값 반환
public static <E extends Comparable<E>> E max(Collection<E> c) {
    if (c.isEmpty()) {
        throw new IllegalArgumentException("컬렉션이 비어있습니다.");
    }
        
    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
   
    return result;
}
  • 파라미터 타입이 Comparable 컬렉션인 경우, 컬렉션 원소의 정렬/검색/최솟값/최댓값을 구하는 것
  • Comparable<T>를 상속한 타입만 들어가게 보장할 수 있음 → 타입 안정성 보장
  • 컬렉션에 담긴 모든 원소가 상호 비교될 수 있어야함 → 컬렉션에 담긴 모든 원소가 같은 타입이어야 한다.

요약

클라이언트에서 따로 형변환 할 필요없이 편리하게 사용할 수 있게 하기 위해 웬만하면 클래스를 제네릭 타입으로 만들어라!

 

일반 클래스 → 제네릭 클래스

1. Class<E>처럼 클래스 선언에 타입 매개변수(E) 추가

  • Stack 클래스를 Stack<E>바꾸고, Object 타입을 E로 바꾸기
public class Stack<E> {
    private E[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new E[DEFAULT_INITIAL_CAPACITY]; // E[]에서 컴파일 에러 발생
    }

    public void push(E e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public E pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        E result = elements[--size];
        elements[size] = null; // 다 쓴 객체 참조 해제

        return result;
    }

    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

 

2. 실체화 불가 타입 E로 배열 선언하는 2가지 방법

E는 실체화 불가 타입이므로 배열로 선언할 수 없는데, 이를 해결할 수 있는 2가지 방법이 있다.

 

방법1 : 제네릭 배열 생성 제약을 우회하는 방법

  1. Object 배열 → 제네릭 배열
  2. 컴파일 에러는 발생하지 않지만, 타입이 불안정해 경고가 발생한다
  • 타입 불안정문제 해결하기
@SuppressWarnings("unchecked")
public Stack() {
	elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
  1. 이 경우에는 타입이 안정한지 증명
    1. Stack<E>의 경우 elements는 private 필드
    2. 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 아예 없다.
    → 비검사 형변환은 확실히 안전하다!
  2. @SuppressWarnings로 경고를 제거해주면 된다
  • 장점
    • 가독성이 좋고 코드 길이가 짧다.
    • 형변환은 배열 생성시 한 버난 하면 된다.
  • 단점
    • E가 Object가 아닌 한 배열의 런타임 타입이 컴파일 타입과 달라 힙 오염이 발생할 수 있다.

 

방법2 : elements는 Object 타입으로 두고, result 타입을 E로 변경하기

1. result의 타입 Object → E

public E pop() {
    ..
    E result = (E) elements[--size]; // Object -> E
    ...
}

E는 실체화 불가 타입이므로 컴파일러는 런타임에서 이뤄지는 형변환이 안전한지 증명할 길이 없다. 이 경우에도 타입 안정성을 직접 증명하고, 경고를 숨기면 된다. 경고는 비검사 형변환을 수행하는 할당문에서만 숨긴다.

 

2. @SuppressWarnings("unchecked") 선언

public E pop() {
    ...
    @SuppressWarnings("unchecked") E result = (E) elements[--size];
    ...
}

elements 타입에는 E 타입만 들어오기 때문에 위 형변환은 안전하므로 @SuppressWarnings 로 경고를 제거한다.

  • 장점
    • 힙 오염 걱정을 안 해도 됨
  • 단점
    • 형변환을 배열에서 원소를 읽을 때마다 해줘야 함

 

 

제테릭 타입 특징

  1. 타입 매개변수에 거의 제약이 없다
    1. Stack<Object>, Stack<int[]>, Stack<List<String>>, Stack 등 가능
  2. 타입 매개변수에 기본 타입은 안됨
    1. Stack<int>, Stack<double> → xxx
  3. 타입 매개변수에 제약 설정 가능함(한정적 타입 매개변수)
    1. DelayQueue<E extends Delayed>

힙 오염

매개변수 유형이 서로 다른 타입을 참조할 때 발생하는 문제이다.

컴파일은 되지만 런타임에 문제가 발생하게 된다(ClassCastException).

ArrayList<String> list1 = new ArrayList<>();
list1.add("홍길동");
list1.add("임꺾정");

// 로직 수행...
Object obj = list1; -> String에서 Object로 업캐스팅
// 로직 수행...

ArrayList<Double> list2 = (ArrayList<Double>) obj;
list2.add(1.0);
list2.add(2.0);

System.out.println(list2); // [홍길동, 임꺾정, 1.0, 2.0]

for(double n : list2) {
    System.out.println(n);
}

 

 

Reference

[제네릭(Generic)] (2) 이것만은 주의해줘

정리

타입 안정성을 지키고 컴파일 에러를 보장하기 위해 로 타입이 아닌 제네릭 타입을 사용하라!

제네릭 타입이란?

클래스/인터페이스에 <타입 매개변수>를 붙인 타입을 의미한다. 제네릭 클래스는 클래스<타입 매개변수>, 제네릭 인터페이스는 인터페이스<타입 매개변수> 형태이다.

  • 제네릭 예시
    • List<E> → E는 정규 타입 매개변수
    • List<String> → String은 실제 타입 매개변수

 

제네릭 타입 사용 시 이점

private final Collection<Stamp> stamps = ...;
  • 타입 안정성
    • 컴파일러가 stamps에는 Stamp 인스턴스만 넣어야함을 인지함
    • stamps에 다른 타입의 인스턴스를 넣으려할 때 컴파일 에러 발생
  • 표현력

 

로 타입

List와 같이 타입 매개변수가 없는 제네릭 타입을 의미

  • 로 타입 사용을 하면 안되는 이유
    • 타입 안정성과 표현력이 없음
    • 런타임 에러 가능성 존재
  • 예시 코드
    • 에러 발견 시기가 컴파일 시점이 아닌 런타임 시점이다.
    // 로 타입
    private final Collection stamps = {...};
    stamps.add(new Coin(...)); 
    
    // 위 코드에서 발생할 수 있는 문제점
    add() 하는 시점에는 에러가 발생하지 않지만
    추후에 런타임 에러 가능성이 있고, 원인 코드를 추적하기가 어려워짐
    
    // 해결책
    private final Collection<Stamp> stamps = {...}; -> 제네릭으로 타입 안정성 확보
    stamps.add(new Coin(...)); -> 컴파일 에러 발생
    

 

로 타입이 만들어진 이유

제네릭이 나오기 전 코드와의 호환성을 위해 로 타입이 만들어졌다.

List vs. List<Object>

  • List
    • 제네릭 타입을 배제한다는 것을 의미
    • List 타입 매개변수에 List<String> 넘길 수 있음 → 타입 안정성 보장되지 않음
public static void main(String[] args) {
		List<String> strings = new ArrayList<>();

		// List 에 List<String> 넘길 수 있음
		unsafeAdd(strings, Integer.valueOf(42));
		strings.get(0); // 컴파일러가 자동 형변환
}

// 매개변수에 로 타입 List 존재
private static void unsafeAdd(List list, Object o) {
		list.add(o);
}
  • List<Object>
    • 모든 타입(Object)을 허용한다는 의사를 컴파일러에 명확히 전달한다는 것을 의미
    • List<Object> 타입 매개변수에 List<String> 넘길 수 없음 → 타입 안정성 보장, 무공변성?
      • List<Object>, List<String>
    public static void main(String[] args) {
    		List<String> strings = new ArrayList<>();
    
    		// List<Object> 에 List<String> 넘길 수 없음
    		safeAdd(strings, Integer.valueOf(42)); -> 컴파일 에러 발생
    }
    
    // 매개변수에 제네릭 타입 List<Object> 존재
    private static void safeAdd(List<Object> list, Object o) {
    		list.add(o);
    }
    

→ 로 타입 말고 제네릭 타입 사용해라

비한정적 와일드 카드 <?>

제네릭 타입을 쓰고 싶지만, 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않을 때 사용하는 타입이다.

Set<?>과 같은 형태로 사용하면 된다. 비한정적 와일드 카드를 사용하면 타입 안정성을 보장하고, 유연한 코드를 작성할 수 있다.

 

 

로 타입을 사용하는 예외 케이스

  1. class 리터럴에는 로 타입을 사용하라
    1. List.class, String[].class, int.class 사용 가능
    2. List<String>.class, List<?>.class 사용 불가능
    Class<List> listClass = List.class; // 가능
    List<?>.class; // 불가능
    
  2. instanceof 연산자
    1. 런타임에는 제네릭 타입 정보가 지워지므로 <?>(비한정적 와일드카드 타입) 외에 매개변수 타입은 instanceof 사용에 적용할 수 없다.
    if (o instanceof Set) { // 로 타입
    	Set<?> s = (Set<?>) o; // (로 타입 -> 와일드 카드 타입) 형변환
    }
    

+ Recent posts