티스토리 뷰

Java

토비의봄#03. Type Intersection

LichKing 2016. 12. 16. 10:06

1. Generic Type Intersection

제네릭은 Bounded Type Parameter, Bounded Wildcard를 이용해 제네릭에 들어오는 타입을 제한할 수 있다.


public class Test {
public static void main(String args[]) {
method01(new ArrayList<A>());
method02(new ArrayList<A>());
}

//Bounded Type Parameter
public static <T extends Serializable> void method01(List<T> list) {

}

//Bounded Wildcard
public static void method02(List<? extends Serializable> list) {

}
}

class A implements Serializable {

}

class B {

}


Serializable을 구현한 A는 method01, method02 모두 컴파일, 런타임 에러없이 실행되지만 B는 컴파일에러가 발생해 컴파일조차 되지 않는다.

타입 1개로 bound를 거는건 실무에서도 어렵지않게 볼 수 있는코드이다. 하지만 제네릭에선 2개 이상의 타입으로 제한을 걸수도있다.


public class Test {
public static void main(String args[]) {
method01(new ArrayList<A>()); // A는 Serializable만 구현할뿐 Cloneable은 구현하지않기때문에 컴파일 에러 발생
method02(new ArrayList<A>());
}

// Type Intersection
public static <T extends Serializable & Cloneable> void method01(List<T> list) {

}

public static <T extends Serializable> void method02(List<T> list) {

}
}

class A implements Serializable {

}


2. Lambda

제네릭에 대한 얘기는 잠시 멈추고 이제 람다로 들어가보자. 람다는 익명클래스를 만들어준다.


public static void main(String args[]) {
method01(str -> str);
}

public static void method01(UnaryOperator<String> operator){
System.out.println(operator.apply("hello"));
}


(str -> str) 이라고 명시한 부분은 컴파일러의 추론으로인해 UnaryOperator<String>의 구현체가 되게된다. 결국 람다도 익명 클래스를 만들기때문에 형변환이 가능하다.


public static void main(String args[]) {
method01((UnaryOperator<String>) str -> str);
}

public static void method01(UnaryOperator<String> operator) {
System.out.println(operator.apply("hello"));
}


제네릭과 함께한 코드를 확인해보자.


public static void main(String args[]) {
method01((UnaryOperator<String>) str -> str);
}

public static <T extends UnaryOperator<String>> void method01(T operator) {
System.out.println(operator.apply("hello"));
}


3. Type Intersection

앞으로 추가적으로 다룰 내용을 이해하려면 이걸 이해해야한다. main() 메서드내의 명시적 형변환은 제거해도 된다. 그럼 코드에 있는 람다표현식으로 제네릭의 다중 제한도 통과할 수 있을까?


public static void main(String args[]) {
// 컴파일 에러 발생
method01(str -> str);
}

public static <T extends UnaryOperator<String> & Serializable> void method01(T operator) {
System.out.println(operator.apply("hello"));
}


타입 파라미터에 의해 인자로 들어온 T는 UnaryOperator와 Serializable을 구현해야한다. 하지만 람다표현식에 의해 전달된 익명 객체는 UnaryOperator만을 구현하기때문에 해당 메서드에 인자로 전달될 수가 없다.


여기서 한가지 유심히 봐야할것은 Serializable 인터페이스이다. Serializable 인터페이스는 마커 인터페이스(Marker Interface)로 내부에 추상메서드가 없다. 즉 구현해야할 메서드가 없다는 것이다. 그렇다면 이런 표현이 가능하다.


public static void main(String args[]) {
// 컴파일 에러 발생
method01((UnaryOperator<String> & Serializable) str -> str);
}

public static <T extends UnaryOperator<String> & Serializable> void method01(T operator) {
System.out.println(operator.apply("hello"));
}


람다표현식으로 구현이 가능한 인터페이스는 '추상 메서드가 1개뿐인 인터페이스' 이다. 위에서 말한대로 Serializable 인터페이스는 추상 메서드가 1개도 없기때문에 2개의 인터페이스를 구현한다고해도 실제 구현해야할 추상 메서드는 1개뿐이다. 그렇기때문에 저런식으로 표현이 가능하다. 저런 표현을 Type Intersection이라 부른다.


4. With Default Method

그럼 이게 무슨 의미가 있을까? 당장 위 예제로만 본다면 Serializable은 직렬화 가능한 클래스가 구현하게된다. Serializable도 같이 구현하는 순간 해당 익명 객체는 직렬화도 가능해짐을 의미하게 된다.

하지만 그건 어디까지나 '저 예제로 뭐가 될까'를 고민했을때 나오는 결과고, 인터페이스에 새롭게 추가된 디폴트 메서드를 이용한다면 인터페이스의 확장이 가능해진다. 예제를 보기전에 잘 생각해보자. 람다는 추상메서드가 1개뿐인 인터페이스를 구현하는 역할을 한다. 람다표현식으로 생성된 객체를 Serializable 을 포함한 타입으로 형변환이 가능했던것은 Serializable 인터페이스에 추상메서드가 없기에 두 타입 모두를 공유하더라도 결국 구현해야할 추상메서드는 1개였기때문이다. 그렇다면 디폴트메서드만 갖고있는 인터페이스를 포함해도 가능하지않을까?


public class Test {
public static void main(String args[]) {
method01((UnaryOperator<String> & Printable) str -> str);
}

public static <T extends UnaryOperator<String> & Printable> void method01(T operator) {
System.out.println(operator.apply("hello"));
System.out.println(operator.print());
}
}

interface Printable {
default String print() {
return "Hello Printable";
}
}


메서드에 인자로 전달되는 operator는 람다표현식으로 구현한 apply() 메서드도 호출할 수 있고 디폴트메서드로 전달되는 Printable의 print() 메서드도 호출할수 있게된것이다!


5. Callback Style

위와 같은 방법만으로도 새로운걸 배운 느낌인데 저 방법은 치명적인 단점이 있다. 추가적인 인터페이스를 전달하고싶어지면 메서드 선언부를 계속 수정해줘야하는 것이다. 이를 해결하기 위해 Callback 방식으로 바꿔보자.


public class Test {
public static void main(String args[]) {
method01((UnaryOperator<String> & Printable & Makeable & Movable) str -> str, t -> {
System.out.println(t.apply("hello"));
System.out.println(t.print());
System.out.println(t.make());
System.out.println(t.move());
});
}

public static <T extends UnaryOperator<String>> void method01(T operator, Consumer<T> consumer) {
consumer.accept(operator);
}
}

interface Printable {
default String print() {
return "Hello Printable";
}
}

interface Makeable {
default String make() {
return "Hello Makeable";
}
}

interface Movable {
default String move() {
return "Hello Movable";
}
}


method01() 메서드는 그자체로는 큰 의미가없다. 타입 추론을 위해 존재하는 operator와 그것으로 인해 추론된 타입을 이용하는 consumer를 받는다. consumer는 Consumer 인터페이스 타입이므로 해당 구현체는 메서드를 실행하는 쪽에서 람다 표현식으로 전달한다.

이 콜백 방식을 이용하게되면 타입 파라미터에 다른 타입들이 추가되더라도 메서드 선언부는 수정할 필요가 없어지게된다.


6. 활용법

사실 위의 예제는 각각의 독립적 인터페이스라서 큰 의미가 없다. 이제 궁극의 활용법을 알아보자.


public class Test {
public static void main(String args[]) {
method01((Supplier<String> & Printable & Makeable & Movable) () -> "LichKing", t -> {
System.out.println(t.get());
System.out.println(t.print());
System.out.println(t.make());
System.out.println(t.move());
});
}

public static <T extends Supplier<U>, U> void method01(T operator, Consumer<T> consumer) {
consumer.accept(operator);
}
}

interface Printable extends Supplier<String> {
default String print() {
return "Hello Printable " + get();
}
}

interface Makeable extends Supplier<String> {
default String make() {
return "Hello Makeable ";
}
}

interface Movable extends Supplier<String> {
default String move() {
return "Hello Movable ";
}
}


기존 UnaryOperator를 Supplier로 전부 교체해주고, 생성한 인터페이스들이 모두 Supplier를 상속받도록 수정하자. 인터페이스끼리는 상속이 가능하며 또한 저렇게 되더라도 구현해야할 추상 메서드는 1개이기때문에 기존 코드는 잘 돌아간다. 독립적이었던 인터페이스들에게 관계를 맺어주고있다고 생각하면 된다.


인터페이스를 좀 더 관계있는 것들로 수정했다. 사실 토비님 방송을 보면서 예제를 그대로 따라치는것보다는 방송을 토대로 내가 예제를 만들어가는식으로 하고싶었는데...이 방송은 그러기엔 너무 어렵다 ㅎㅎ


public class Test {
public static void main(String args[]) {
method01((Supplier<String> & Printable & UpperCase & LowerCase) () -> "LichKing", t -> {
System.out.println(t.get());
System.out.println(t.print());
System.out.println(t.upperCase());
System.out.println(t.lowerCase());
});
}

public static <T extends Supplier<U>, U> void method01(T operator, Consumer<T> consumer) {
consumer.accept(operator);
}
}

interface Printable extends Supplier<String> {
default String print() {
return "Hello Printable " + get();
}
}

interface UpperCase extends Supplier<String> {
default String upperCase() {
return get().toUpperCase();
}
}

interface LowerCase extends Supplier<String> {
default String lowerCase() {
return get().toLowerCase();
}
}


Supplier를 상속받는 인터페이스들이 일제히 get()을 호출하고있다. 그리고 그 반환값을 조작함으로써 잘 모르겠지만... 여튼 서로간에 뭔가 관계들이 있는듯 보인다.


7. 궁극적 활용법

위 활용법은 '이런식으로 쓸 수 있다.' 라는 소개성 예제고 이제 좀 더 심화된 예제를 봐보자.


interface ValueBoxable<T> {
T getFirstValue();

T getSecondValue();

void setFirstValue(T t);

void setSecondValue(T t);
}

class ValueBox implements ValueBoxable<String> {
private String firstValue;
private String secondValue;

public ValueBox(String firstValue, String secondValue) {
this.firstValue = firstValue;
this.secondValue = secondValue;
}

@Override
public String getFirstValue() {
return this.firstValue;
}

@Override
public String getSecondValue() {
return this.secondValue;
}

@Override
public void setFirstValue(String s) {
this.firstValue = s;
}

@Override
public void setSecondValue(String s) {
this.secondValue = s;
}
}

예제를 위한 준비 코드1


interface Forwarding<T> extends Supplier<ValueBoxable<T>>, ValueBoxable<T> {
@Override
default T getFirstValue() {
return get().getFirstValue();
}

@Override
default T getSecondValue() {
return get().getSecondValue();
}

@Override
default void setFirstValue(T t) {
get().setFirstValue(t);
}

@Override
default void setSecondValue(T t) {
get().setSecondValue(t);
}
}

예제를 위한 준비코드2

Supplier와 앞서 만든 ValueBoxable을 상속받는다. ValueBoxable의 모든 추상 메서드들은 디폴트 메서드로 구현한다. 딱봐도 람다로 get()만 구현하기위함임을 알아야한다.


public class Test {
public static void main(String args[]) {
ValueBoxable box = new ValueBox("첫번째", "두번째");
method01((Forwarding<String>) () -> box, t -> {
System.out.println(t.getFirstValue());
System.out.println(t.getSecondValue());
});
}

public static <T extends Supplier<U>, U> void method01(T operator, Consumer<T> consumer) {
consumer.accept(operator);
}
}

예제를 위한 준비코드3

그리고 이런짓을 해보자. 람다로 추론하는 타입은 Supplier이기때문에 명시적으로 Forwarding으로 바꿔준다. 그럼 Forwarding이 상속받고 있는 인터페이스들의 디폴트 메서드를 모두 사용할 수 있게된다. 결과는 직접 확인해보자.


이제서야 준비코드가 모두 끝났다.(;;;;;)

이제 이 인터페이스를 추가한다.


interface Changeable<T> extends Supplier<ValueBoxable<T>> {
default void change(UnaryOperator<T> operator) {
ValueBoxable<T> box = get();
box.setFirstValue(operator.apply(box.getFirstValue()));
box.setSecondValue(operator.apply(box.getSecondValue()));
}
}


box를 조작하는 역할을 하는 인터페이스이다. 디폴트메서드로만 구성된다.


public static void main(String args[]) {
ValueBoxable box = new ValueBox("Lich", "King");
method01((Forwarding<String> & Changeable<String>) () -> box, t -> {
t.change(String::toUpperCase);
System.out.println(t.getFirstValue());
System.out.println(t.getSecondValue());
t.change(String::toLowerCase);
System.out.println(t.getFirstValue());
System.out.println(t.getSecondValue());
});
}


이제 궁극의 마지막 코드이다. 람다 표현식으로 구현된 클래스의 타입을 추가하고 활용한다.


사실 앞에 두 방송은 보면서 어느정도는 이해하고 따라갔었다. 때문에 방송을 보고 포스팅을 작성할때는 다시 방송을 볼일은 없었는데...이번 방송은 포스팅을 하다보니 도저히 어떤 코드가 나타났었는지 구현할 수 가없어 방송도 몇번씩이나 보게됐다. 그러나 아직도 이해가 잘 안간다ㅜㅜ 코드를 몇번은 더 돌아봐야할 것 같다.

'Java' 카테고리의 다른 글

Junit, Hamcrest 테스트케이스  (0) 2016.12.23
토비의봄#04. Reactive Programming 1  (0) 2016.12.22
토비의봄#03. Type Intersection  (0) 2016.12.16
토비의봄#02. Super Type Token  (0) 2016.11.12
토비의봄#01. Double Dispatch  (4) 2016.11.07
Java8#05. Optional  (0) 2016.10.31
공유하기 링크
TAG
댓글
댓글쓰기 폼