경계가 모호
제네릭 컨테이너는 타입이 V 인 속성을 갖는다. (ex. 수조의 경우 V = Double)
클라이언트는 제네릭 컨테이너를 서로 영구적으로 연결할 수 있다.
public interface ContainerLike<V> {
V get(); // 'getAmount'의 일반화
void update(V value); // 'addWater'의 일반화
// void connectTo(T other); // 'connectTo'의 일반화
}
update: 컨테이너 그룹에 따라 구체적인 로직이 달라지지만, API 만으로 확인이 불가능하다. 🤦♂️
2진 메서드
- 같은 클래스의 객체를 인자로 받는 메서드
자바와 같은 객체지향 언어의 타입 시스템은 주어진 클래스나 인터페이스의 모든 서브클래스가 특정 형태의 2진 메서드를 갖도록 강제할 수 없다.
public interface Comparable {
int compareTo(specificType other); // specificType -> Comparable 인터페이스를 구현하는 클래스
}
equals의 파라미터를 Object로 선언하고 인자의 타입이 적절한지는 서브클래스
가 런타임에 확인한다.
Comparable 인터페이스의 경우 제네릭으로 해결한다.
인터페이스의 타입 파라미터로 T를 지정한다.
compareTo 메서드의 파라미터를 타입 T로 선언한다.
타입 안전성은 높아지지만 다음과 같은 유명한 오.남용 사례도 가능하다.
class Apple implements Comparable<Orange> { ... }
void connectTo(Object other)
: 타입 안전성 포기, 컴파일러 도움 X, 런타임 타입 확인 필요 (instanceof) 0점
void connectTo(ContainerLike<V> other)
: ContainerLike 타입은 보장할 수 있지만 V 타입은 강제할 수 없다 20점
'Comparable' 인터페이스에 적용한 제네릭을 사용하자
class Employee implements Comparable<Employee> { ... } // Comparable 의 올바른 사용법
class Employee implements Comparable<Apple> { ... } // ?
public interface ContainerLike<V, T extends Container<V, T>> { // 같은 인터페이스를 구현한 타입으로 강제하자!
V get();
void update(V val);
void connectTo(T other);
}
주의: 같은 인터페이스를 구현한 같은 클래스이기를 강제할 수는 없다.
seed
메서드 - 새로운 제네릭 컨테이너는 그룹의 요약 값을 초기화할 수 있다
report
메서드 - 제네릭 컨테이너의 get 메서드에서는 요약 값을 V 타입의 지역적인 값으로 바꿔주는 메서드가 필요하다
update
메서드 - 제네릭 컨테이너의 update 메서드는 요약 값을 수정해야 한다
merge
메서드 - connectTo 메서드에서는 두 요약 값을 병합하는 메서드가 필요하다
public interface Attribute<V, S> {
S seed(); // 요약 값을 초기화
V report(S summary); // 요약 값을 해석
void update(S summary, V value); // 주어진 값을 이용해 요약 값을 갱신
S merge(S summary1, S summary2); // 두 요약 값을 병합
}
// 속성 값 포함 X, 무상태
제네릭 컨테이너의 메서드 | 속성(Attribute)의 메서드 |
---|---|
생성자 | S seed() |
V get | V report(S summary) |
void update(V val) | void update(S summary, V value) |
void connectTo(T other) | S merge(S summary1, S summary2) |
Attribute 객체는 속성 값을 포함하지 않으므로 무상태라는 점에 주목하자
S 타입의 객체(그룹 요약)와 V 타입의 객체(캐싱된 지역 값)는 제네릭 컨테이너 안에 별도로 저장된다.
Attribute 인터페이스는 자바 8에서 스트림 연산 결과를 하나의 값으로 수집할 때 사용하는 인터페이스와 매우 비슷하다.
일반적인 컬렉션을 하나의 가변적인 결과로 수집하는 과정을 생각해보자
일종의 summary 객체를 초깃값으로 초기화한 후 컬렉션의 모든 요소에 대해 갱신할 것이다.
모든 요소를 스캔한 후 다른 타입 즉 결과 타입으로 변환한다.
Collection<V> collection = ...
for (V value: collection) { // 1. 요약 값을 초기화
summary.update(value); // 2. 주어진 값을 이용해 요약 값을 갱신
}
Result result = summary.toResult(); // 3. 요약 값을 결과로 변환
// ...
// 4. 병렬 컬렉터에 의한 병합 연산
요약 값의 타입을 S, 최종 결과의 타입을 R이라고 하면 Collector 인터페이스에는 다음과 같은 메서드가 필요하다.
S supply(); // 1. 요약
void accumulate(S summary, V value); // 2. 주어진 값을 이용해 요약 값을 갱신
S combine(S summary1, S summary2); // 3. 두 요약 값을 병합
R finish(S summary); // 4. 요약 값을 결과로 반환
인터페이스 | 추상 메서드의 타입 | 역할 |
---|---|---|
Supplier\<S> | void -> S | 요약 초깃값을 제공 |
BiConsumer<S, V> | (S, V) -> void | 주어진 값을 이용해 요약 값을 갱신 |
BinaryOperator\<S> | (S, S) -> S | 두 요약 값을 병합 |
Function<S, R> | S -> R | 요약 값을 결과로 변환 |
public interface Collector<V, S, R> {
Supplier<S> supplier(); // 1. 요약 초깃값을 제공
BiConsumer<S, V> accumulator(); // 2. 주어진 값을 이용해 요약 값을 갱싱
BinaryOperator<S> combiner(); // 3. 두 요약 값을 병합
Function<S, R> finisher(); // 4. 요약 값을 결과로 반환
}
Collector<String, StringBuilder, String> concatenator =
new Collector<>() { // 1. 외부 익명 클래스
@Override
public Supplier<StringBuilder> supplier() { // 2. 요약 초깃값을 제공
return new Supplier<>() { // 3. 첫 번째 내부 익명 클래스
@Override
public StringBuilder get() {
return new StringBuilder();
}
이미 존재하는 메서드나 생성자를 함수형 인터페이스의 인스턴스로 변환하는 새로운 표현식 '::'으로 표기
메서드 참조를 이용해 인스턴스 메서드를 적당한 인터페이스로 바꿀 수 있다.
ToIntFunction<Object> hasher = Object::hashCode;
ToIntFunction는 다음과 같은 유일한 메서드를 포함하는 함수형 인터페이스
int applyAsInt(T item)
메서드 참조는 특정 객체
의 메서드도 참조할 수 있다.
Consumer<String> printer = System.out::println;
정적 메서드와 생성자에도 메서드 참조를 적용할 수 있다.
StringBuilder 생성자의 참조를 이용해 제공자 Supplier 를 만들 수 있다.
컴파일러는 생성자를 Supplier 타입으로 세심하게 감싼다.
Collector<String, StrinbBuilder, String> concatenator = new Collector<>() {
@Override
public Supplier<StringBuilder> supplier() {
return StringBuilder::new; // 생성자를 참조
}
}
Collector 클래스에서 제공하는 정적 메서드 of 를 이용하면 외부 익명 클래스도 필요 없이 다음과 같은 간단한 코드를 작성할 수 있다.
Collector<String, StringBuilder, String> concatenator =
Collector.of(StringBuilder::new, // 1. 제공자(생성자를 참조)
StringBuilder::append, // 2. 갱신 함수
StringBuilder::append, // 3. 병합 함수(또 다른 append 메서드)
StringBuilder::toString); // 4. 종결자
메서드 참조에서는 메서드 시그니처는 지정할 수 없고 메서드 이름만 지정한다.
컴파일러는 메서드 참조가 수행된 곳의 문맥을 바탕으로 메서드 시그니처를 추론하여 함수형 인터페이스를 지목해야 한다.
예를 들어 위의 코드 조각에서 갱신 함수의 참조는 StringBuilder의 메서드 중에서 다음과 같은 메서드를 선택한다.
public StringBuilder append(String s)
문맥에 비춰보면 BiConsumer<StringBuilder, String> 이 필요하기 때문이다.
여기서 append는 값을 리턴하지만 BiConsumer는 void를 리턴하는 불일치가 존재한다.
값을 리턴하는 메서드를 호출한 후 리턴 값을 무시할 수 있듯이 컴파일러가 이를 알아서 처리한다.
메서드 | 목표 함수형 인터페이스 | |
---|---|---|
시그니처 | SB append(String s) | BiConsumer<SB, String> |
타입 | (SB, String) -> SB | (SB, String) -> void |
값을 리턴하는 메서드의 참조를 void 함수형 인터페이스의 참조에 대입할 수 있다.
이제 병합 함수의 메서드 참조를 살펴보자
문맥에 따르면 BinaryOperator 즉 (this를 포함해) 두 StringBuilder를 인자로 받아 StringBuilder를 리턴하는 메서드가 필요하다.
StringBuilder 클래스의 또 다른 append 메서드가 이러한 역할을 한다.
public StrinbBuilder append(CharSequence seq)
여기서 목표로 하는 함수형 인터페이스는 인자 타입으로 StringBuilder를 기대하지만 append 메서드는 CharSequence를 인자로 받으므로 타입 변환이 필요한데 CharSequence가 StringBuilder의 슈퍼 타입이므로 이러한 변환이 가능하다.
메서드 | 목표 함수형 인터페이스 | |
---|---|---|
시그니처 | SB append(CharSequence seq) | BinaryOperator\<SB| |
타입 | (SB, CharSequence seq) -> SB | (SB, String) -> SB |
타입 T를 인자로 받는 메서드의 참조를 T의 서브 타입을 인자로 받는 메서드에 상응하는 함수형 인터페이스의 참조에 대입할 수 있다.
여기서 다룬 concatenator는 JDK의 정적 메서드 Collectors.joining()이 리턴하는 컬렉터 객체와 매우 비슷하다.
public static Collector<CharSequence, ?, String> joining() {
return new CollectorImpl<CharSequence, StringBuilder, String>(
StringBuilder::new, StringBuilder::append,
(r1, r2) -> { r1.append(r2); return r1; },
StringBuilder::toString, CH_NOID);
}
Attribute 타입의 객체를 리턴하는 어댑터 정적 메서드
public static Attribute<V, S> of(Supplier<S> supplier,
BiConsumer<S, V> updater,
BinaryOperator<S> combiner,
Function<S, V> finisher) {
return new Attribute<>() { // 1. 익명 클래스
@Override
public S seed() {
return supplier.get();
}
@Override
public void update(S summary, V value) {
updater.accept(summary, value);
}
@Override
public S merge(S summary1, S summary2) {
return combiner.apply(summary1, summary2);
}
@Override
public V report(S summary) {
return finisher.apply(summary);
}
}; // 2. 익명 클래스
}
연결 상태와 그룹을 관리하는 ContainerLike의 제네릭 구현
public class Container {
private Container parent = this;
private double amount;
private int size = 1;
}
그룹 요약 값을 저장하는 S 타입의 객체와 요약 값, 지역적인 값을 조작하는 메서드로 이뤄진 Attribute 타입의 객체를 필드로 포함
이 두 필드가 amount 필드를 대체한다.
public class UnionFindNode<V, S> implements ContainerLike<V, UnionFindNode<V, S>> {
private UnionFindNode<V, S> parent = this;
private int groupSize = 1;
private final Attribute<V, S> attribute; // 속성을 조작하는 메서드를 포함한다.
private S summary;
public UnionFindNode(Attribute<V, S> dom) {
attribute = dom;
summary = dom.seed();
}
@Override
public V get() { // 현재 속성을 리턴
UnionFindNode<V, S> root = findRootAndCompress();
return attribute.report(root.summary, value);
}
@Override
public void update(V value) { // 속성을 갱신
UnionFindNode<V, S> root = findRootAndCompress();
attribute.update(root.summary, value);
}
@Override
public void connectTo(UnionFindNode<V, S> other) {
UnionFindNode<V, S> root1 = findRootAndCompress();
UnionFindNode<V, S> root2 = findRootAndCompress();
if (root1 == root2) return;
...
S newSummary = attribute.merge(root1.summary, root2.summary);
... // 크기에 따른 병합 정책
}
}
추상화 레벨이 추가된다.
getAmount와 addWater 메서드 대신 ContainerLike 인터페이스가 제공하는 제네릭 메서드인 get과 update를 사용한다.
Container a = new Container();
Container b = new Container();
Container c = new Container();
Container d = new Container();
a.update(12.0);
d.update(8.0);
a.connectTo(b);
System.out.println(a.get() + " " + b.get() + " " + c.get + " " + d.get());
UnionFindNode를 구현하는 모든 구체 클래스는 타입 V와 S를 지정하고 Attribute<V, S> 타입의 객체를 지정해야 한다.
Attribute 객체는 자신이 속한 UnionFindNode 객체에 접근할 수 없고 groupSize 필드에도 접근할 수 없다.
따라서 그룹 크기 관련 정보를 요약 값 안에 별도로 복사해 저장해야한다.
결국 단순하게 S = Double 로 하지 않고 그룹의 요약 값 역할을 할 클래스를 따로 만들어야 하는데 이 클래스를 ContainerSummary 라고 하자.
이 요약 정보는 그룹 전체에 담긴 물의 양과 수조 개수를 포함한다.
class ContainerSummary {
private double amount;
private int groupSize;
public ContainerSummary(double amount, int groupSize) {
this.amount = amount;
this.groupSize = groupSize;
}
public ContainerSummary() {
this(0, 1);
}
public void update(double increment) {
this.amount += increment;
}
public ContainerSummary merge(ContainerSummary other) {
return new ContainerSummary(amount + other.amount, groupSize + other.groupSize);
}
public double getAmount() {
return amount / groupSize;
}
public static final Attribute<Double, ContainerSummary> ops
= Attribute.of(ContainerSummary::new, ContainerSummary::update, ContainerSummary::merge, ContainerSummary::getAmount);
}
}
update, merge, report 메서드의 첫 번째 인자는 그에 상응하는 ContainerSummary 메서드에서 this로 연결된다.
구체적인 요약 값 타입과 그 지원 메서드를 정의했다면 단 세 줄의 코드로 지금까지 다룬 전형적인 수조의 기능을 재현할 수 있다.
다음 코드처럼 UnionFindNode를 상속받고 그 생성자에 적절한 Attribute 객체를 넘겨주면 된다.
public class Container extends UnionFindNode<Double, ContainerSummary> {
public Container() {
super(ContainerSummary.ops);
}
}
자바 제네릭의 한계점 - 모든 UnionFindNode 객체가 같은 Attribute 객체 참조를 포함한다.
public class UnionFindNode<V, S>
implements ContainerLike<V, UnionFindNode<V,S>> {
private UnionFindNode<V, S> parent = this;
private int groupSize = 1;
private static final Attribute<V,S> attribute; // compile error
private S summary;
public class Post extends UnionFindNode<Integer,PostSummary> {
public Post() {
super(PostSummary.ops);
}
}
class PostSummary {
private int likeCount;
public PostSummary(int likeCount) {
this.likeCount = likeCount;
}
public PostSummary() {}
public void update(int likes) {
likeCount += likes;
}
public PostSummary merge(PostSummary summary) {
return new PostSummary(likeCount + summary.likeCount);
}
public int getCount() {
return likeCount;
}
public static final Attribute<Integer,PostSummary> ops =
Attribute.of(PostSummary::new,
PostSummary::update,
PostSummary::merge,
PostSummary::getCount);
}
컨트롤러: 사용자 입력에 대응하는 컴포넌트
모델: 응용 프로그램 관련 데이터를 저장하는 컴포넌트
뷰: 사용자에게 데이터를 보여주는 컴포넌트
public interface ParametricFunction {
int getNParams();
String getParamName(int i);
double getParam(int i);
void setParam(int i, double val);
double eval(double x);
}
public abstract class AbstractFunction implements ParametricFunction {
private final int n;
protected final double[] a;
public AbstractFunction(int n) {
this.n = n;
this.a = new double[n];
}
public int getNParams() { return n; }
public String getParamName(int i) {
final int firstLetter = 97; // a
return Character.toString(firstLetter + i);
}
public double getParam(int i) {
return a[i];
}
public void setParam(int i, double val) {
a[i] = val;
}
}
public class ObservableFunction implements ParametricFunction {
private final ParametricFunction f;
private final List<ActionListener> listeners = new ArrayList<>();
private final ActionEvent dummyEvent = new ActionEvent(this, ActionEvent.ACTION_FIRST, "update");
public ObservableFunction(ParametricFunction f) { this.f = f; }
public int getNParams() { return f.getNParams(); }
public double getParam(int i) { return f.getParam(i); }
public String getParamName(int i) { return f.getParamName(i); }
public double eval(double x) { return f.eval(x); }
public void addActionListener(ActionListener listener) {
listeners.add(listener);
}
public void setParam(int i, double val) {
f.setParam(i, val);
for (ActionListener listener: listeners)
listener.actionPerformed(dummyEvent);
}
}
Executorservice
를 사용한 카페 주문 기능 구현에서 해당 패턴을 사용해봤습니다. Publisher, Subscriber Pattern Sample Code