javac 컴파일의 편의문법 제거 대상인 제네릭과 개발자의 코드 추가 로직인 애너테이션 프로세서에 대해 알아보자.
Object obj = "a";
Object[] strArr = new String[10]; // 공변
strArr[1] = 10; // 런타임 할당시 에러
배열은 공변이다. String 이 Object 의 하위 타입이라면 String[] 도 Object[] 의 하위 타입이다.
따라서 Object[] 정적 타입 변수에 String 값을 추가하는 것은 컴파일 타임에 에러를 낼 수 없다.
ArrayList list = new ArrayList();
list.add(Integer.valueOf(10)); // 컴파일, 런타임 에러 X
list.add("b"); // 컴파일, 런타임 에러 X
제네릭이 있기 전 ArrayList(컬렉션) 은 타입이 없다. 따라서 하나의 컬렉션에는 여러 타입을 넣을 수 있다.
매개 변수화 된 타입인 제네레릭은 타입에 국한되지 않은 추상적인 코드를 만들 수 있게 한다.
하지만 위 처럼 제네릭이 있기 전의 컬렉션은 모든 타입을 받았다.
제네릭 컬렉션을 만들면서 기존 컬렉션 코드를 문제되게 하지 않으려면,
자바는 2번을 선택했다.
JDK 1.2 시절 Vector -> ArrayList, HashTable -> HashMap 과 같이 1번의 선택으로 컬렉션 라이브러리를 이미 변경한 탓에 또 1번을 선택하면 Vector<Int>, ArrayList<Int> 와 같이 너무 많은 클래스가 생겨 불편하기 때문이다.
이를 타입 소거 라고 한다.
하위 호환성을 위한 타입 소거로 인해 제네릭 컬렉션의 타입 제한은 컴파일 타임에 형변환 코드 가 자동 생성되어 동작한다.
문제의 원인은 자바 컬렉션 라이브러리는 내부에서 애초에 Object[] 를 사용했다는 것이다.
ArrayList 의 add는 아래 처럼 Object[] 에 값을 저장한다.
따라서 요소는 Object 의 하위 타입이어야 한다.
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
타입 변환 으로 제네릭을 구현한 이상 제네릭 컬렉션의 모든 요소는 Object 타입의 하위 타입이어야 한다는 결론이 난다.
이것이 제네릭에서 기본 타입을 쓰지 못하는 이유다. primitive 타입은 object 의 하위가 아니기 때문이다. 타입 소거 방식을 택할 수 밖에 없었기 때문에 발생한 문제다.
다행히 원소로 객체 타입만을 쓸 수 있다는 문제는 코드 레벨에서는 없다.
primitive 타입을 원소를 add 하는 코드를 작성하더라도 박싱/언박싱을 자동으로 해주기 때문이다.
진짜 문제는 박싱/언박싱 자체가 느리고 양도 많아서, 자바 제네릭의 주요한 성능적 문제라는 것이다.
박싱/언박싱을 근본적으로 없애기 위해서는 다음 중 하나가 만족되어야 한다.
List<int>와 List<Object>를 다른 클래스로 다루기값 타입(value type) 지원이상의 방식은 모두 타입 자체를 유지하여 타입 소거가 되지 않도록 하는 방법이다. 외에도 여러 방식이 있다.
이 작업은 발할라 프로젝트라는 이름으로 진행중이다. 오랫동안.
class Test {
List<String> list;
}
위 list 의 제네릭으로 선언된 원소 타입 <String> 은 런타임에 알 수 없다.
타입이 소거되기 때문이다.
따라서 필요하다면 인자로 가지고 다녀야 한다.
다만 Signature 클래스 파일 속성에 제네릭 컬렉션 전체 타입이 적히긴 한다.
우린 리플렉션으로 이를 사용할 수 있다.
Field f = Test.class.getDeclaredField("list");
System.out.println(f.getGenericType());
// 결과 : java.util.List<java.lang.String>
getGenericType() 의 리턴값에서 String 만 따로 추출할 방법은 없다. 정말 필요하다면 파싱을 해야 한다.
public void sum(List<Integer> a) {}
public void sum(List<Double> a) {}
위 두 메서드의 시그니쳐는 분명히 다르지만 오버로딩이 불가하다.
런타임에는 둘의 타입 매개변수가 소거되므로 두 시그니쳐가 같아지기 때문이다.
애너테이션 프로세서는 컴파일 동작을 개발자가 간섭할 수 있는 기능이다.
간단하게는 코드를 자동으로 추가하는 기능이며
정확하게 말하면 컴파일 타임에 추상 구문 트리를 수정하는 기능이다.
javac -processor [구현체 클래스] 로 실행한다.// 아래 애너테이션 둘다 필요
@SupportedAnnotationTypes("*") // 프로세서에 사용할 애너테이션 클래스 지정
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class CustomAnnotationProcessor extends AbstractProcessor {
// 찾은 요소에 대해 실행할 클래스. 따로 적진 않을 예정
// 예시의 구현은 ProcessingEnvironment 의 messager 를 이용한 컴파일 에러 출력
private Validator validator;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
validator = new Validator(processingEnv.getMessager());
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
// 프로세서 대상을 선택한다. 이 예시에서는 루트 요소만을 검사한다.
// 애너테이션을 대상으로 하려면 getElementsAnnotatedWith() 를 사용한다.
Set<? extends Element> rootElements = roundEnv.getRootElements();
for (Element element : rootElements) {
validator.validate(element);
}
}
return false;
}
}