
3 장은 람다식과 관련된 방대한 내용을 설명한다.
특히 람다식은 함수형 인터페이스, 메서드 참조, 그리고 (아직 교재에서 보여주진 않았지만) 스트림과의 긴밀한 연관을 갖고 있다.
Java 를 이용해 개발하다 보면 동일한 코드를 반복적으로 사용 하거나, 코드의 핵심은 아니지만 불가피하게 많은 코드를 적어야 하는 상황이 일어나곤 한다.
Java 8 이전에는 이를 Anonymous 객체를 이용해 해소하고자 하였으나, 이마저도 완전히 해소하지는 못했다.
/* sort a list */
list.sort(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1.compareTo(o2);
}
});
/* and add some elements to list */
/* and sort again.... */
list.sort(new Comparator<Integer>() { // too long!!
@Override
public int compare(Integer o1, Integer o2) {
return o1.compareTo(o2);
}
});
Java 8 디자이너들은 이처럼 (비교적) 의미 없는 코드를 많이 줄이고자 하였고, 결국 함수형 인터페이스, 람다식, 스트림 등을 언어에 새롭게 추가하였다.
/* sort a list */
list.sort(Integer::compareTo);
/* and add some elements to list */
/* and sort again.... */
list.sort(Integer::compareTo); // Wow!
함수형 인터페이스란 인터페이스 내, 추상 메서드가 단 하나만 존재하는 인터페이스를 말한다.
이 때, default, static 메서드의 개수는 상관하지 않는다. [1]
Java 8 부터 함수형 인터페이스를 위한 @FunctionalInterface 어노테이션이 추가되었는데, 이는 인테페이스 내 추상메서드가 단 하나만 존재하는지 확인해주는 어노테이션이다. [2]
@FunctionalInterface
interface Valid { // 컴파일 에러 X
void foo();
default void defaultMethod() {};
static void staticMethod() {};
}
@FunctionalInterface
interface Invalid { // 컴파일 에러 : 추상 메서드 2 개 존재
void foo();
void bar();
}
람다식은 () 를 이용해 메서드에 전달된 매개변수를 표기하고, -> 이후 람다식의 행동을 정의할 수 있다.
(int a, int b) -> {
System.out.println(a + b);
return a + b;
}
(a, b) -> System.out.println(a + b); // both are same
(a, b) -> {System.out.println(a + b);} // both are same
위처럼 ( ... ) 에 매개변수의 타입을 명시할수도 있으며, -> 이후 { ... } 를 통해 람다식의 구체적 행동을 묘사할 수 있다.
람다식을 이용하면 함수형 인터페이스의 추상 메서드를 간단히 구현할 수 있다.
Valid consumer
= () -> System.out.println("Call abstract method " +
"via Lambda expression!");
consumer.foo();
Call abstract method via Lambda expression!
이는 람다식으로 추상 메서드의 행동을 정의한 것으로, "행동의 매개변수화" (Behavior parameterization) 가 일어난 것이다.
하지만 람다식과 추상 메서드를 연결하기 위해선, “추상 메서드의 signature 가 람다식을 설명” 할 수 있어야 한다.
즉, 추상 메서드가 String foo(int, int); 처럼 선언되었으면, 람다식 또한 (int, int) 매개변수를 받고 String 형 return 이어야 한다는 것이다.
// function descriptor A and B should be same,
// if not compile error occurs
@FunctionalInterface
interface Inter {
String foo(int, int);
// (int, int) -> String : function descriptor A
}
// (int, int) -> String : function descriptor B
Inter func = (int a, int b) -> String.valueOf(a + b);
교재에서는 이를 편하게 생각하기 위해 Function Descriptor 를 소개한다.
Function Descriptor 는 메서드에 필요한 매개변수 타입과, 메서드의 리턴 타입을 나타낸 표기법으로, (매개변수 1 타입, 매개변수 2 타입, ... ) -> 리턴 타입 처럼 표기한다.
💡 사실 교재에 적힌 바로는 함수형 인터페이스의 추상 메서드 signature 를 Function Descriptor 라 정의하지만, 위처럼 생각하는게 편해서 고쳐 적었다.
즉, 어떤 함수형 인터페이스의 추상 메서드이든, 어떤 람다식이든, Function Descriptor 만 서로 맞춘다면 실행 가능하다는 것이다.
(하지만 사실 다르게 말하면, 우리가 사용하기 위한 함수형 인터페이스의 추상 메서드가 어떤 형태인지 정확히 알고 있어야 된다는 뜻이기도 하다…. ㅠ)
java.util.function 패키지에 존재하는 대표적 함수형 인터페이스java.util.function 패키지는 Java 8 에 추가되어, 다양한 함수형 인터페이스가 존재하는 패키지이다.
또한 공식 API 문서에 따르면, 해당 패키지에는 Function, Consumer, Predicate, Supplier 의 4 가지 “기본 함수 형태” 가 존재한다. [3]
이들은 모두 함수형 인터페이스로, 사용 용도에 따라 적절히 import 하여 사용하길 권장하고 있다.
(굳이 함수형 인터페이스를 직접 만들지 않아도 된다는 뜻)
이들의 function descriptor 를 정리해보자면 다음과 같다.
| Functional Interface | Abstract Method | Function Descriptor |
|---|---|---|
Predicate<T> | boolean test(T t) | T -> boolean |
Consumer<T> | void accept(T t) | T -> () |
Supplier<T> | T get() | () -> T |
Function<T, R> | R apply(T t) | T -> R |
Predicate<T> : T -> boolean@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
/* 생략 */
}
Predicate<T> 인터페이스는 인자 T t 를 이용한 boolean 술어를 나타낼 수 있다.
쉽게말해 어떤 타입 T 의 매개변수를 이용해, true or false 인지 test 하기 위한 인터페이스인 것이다.
이를 이용해 다음처럼 우리가 원하는 조건식을 전달하여, List 원소를 filter 하는 메서드를 구현, 이용할 수 있다.
static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> result = new ArrayList<T>();
for (T t : list)
if (p.test(t)) // Predicate : T -> boolean
result.add(t);
return result;
}
List<Integer> numbers = List.of(1, 2, 3, 4, 5,
6, 7, 8, 9, 10);
List<Integer> oddNumbers = filter(
numbers, // List<Integer>
i -> i % 2 != 0 // Lambda : int -> boolean
);
/* Function descriptor of Lambda & Predicate should be same */
List<Integer> powerOfThree = filter(
numbers, // List<Integer>
i -> i % 3 == 0 // Lambda : int -> boolean
);
System.out.println(oddNumbers);
System.out.println(powerOfThree);
[1, 3, 5, 7, 9]
[3, 6, 9]
Consumer<T> : T -> ()@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
/* 생략 */
}
Consumer<T> 인터페이스는 인자 T t 를 이용해 결과를 반환하지 않는 연산을 나타낼 수 있다.
이름 그대로 t 를 이용한 "소비" (소비는 물건을 제공하지 않고 사는 것이므로) 연산만 진행하는 것이다.
때문에 Consumer<T> 는 어느 객체에 대한 작업을 매개변수화 하고자 할 때 자주 이용된다.
static <T> void forEach(Consumer<T> consumer, List<? super T> list) {
for (Object t : list) consumer.accept((T) t);
}
// logger : java.util.logging.Logger
Consumer<?> logging = obj -> logger.info(obj.toString() + " received");
Consumer<String> printing = str -> System.out.println(str);
forEach(printing, List.of("Hello!!", "World!!"));
forEach(logging, List.of(1, 3, "Hello?"));
Hello!!
World!!
7월 30, 2024 9:42:38 오전 chapter3.main lambda$main$1
INFO: 1 received
7월 30, 2024 9:42:38 오전 chapter3.main lambda$main$1
INFO: 3 received
7월 30, 2024 9:42:38 오전 chapter3.main lambda$main$1
INFO: Hello? received
Supplier<T> : () -> T@FunctionalInterface
public interface Supplier<T> {
T get();
/* 생략 */
}
반대로 Supplier<T> 는 Consumer<T> 의 반대이다. Supplier<T> 는 어떠한 인자도 받지 않고 그저 T 형 객체를 제공해주는 역할을 수행한다.
때문에 Supplier<T> 는 어느 요청마다 특정 개체를 반환 또는 생성하는 경우에 자주 쓰인다.
class DTO {
int value;
String header;
// construct empty DTO
public DTO() {}
// construct DTO via parameters
public DTO(int value, String header) {
this.value = value;
this.header = header;
}
@Override
public String toString() {
return "DTO{" + "value=" + value +
", header='" + header + '\'' + '}';
}
}
Supplier<DTO> supplyEmptyDTO = () -> new DTO();
Supplier<DTO> supplyDTO = () -> new DTO(10, "hello");
DTO emptyDTO = supplyEmptyDTO.get();
DTO dto = supplyDTO.get();
System.out.println(emptyDTO);
System.out.println(dto);
DTO{value=0, header='null'}
DTO{value=10, header='hello'}
Function<T, R> : T -> R@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
/* 생략 */
}
Funtion<T, R> 인터페이스는 T t 인자를 이용해 연산 후, R 형 객체를 제공하는 역할을 수행한다.
이 또한 이름 그대로 함수의 역할을 수행한다는 것이다.
때문에 Function<T, R> 은 객체의 어느 속성에 기반해 객체를 맵핑하거나, 특정 연산을 매개변수화 할 때 자주 사용된다.
static <T, R> List<R> mapViaFilter(
List<T> list, Predicate<T> filter, Function<T, R> map
) {
List<R> result = new ArrayList<>();
for (T t : list) if (filter.test(t))
result.add(map.apply(t));
return result;
}
List<String> list = List.of("Hello", "world", "Goodbye", "world");
Predicate<String> filterUppers= s -> !s.toLowerCase().equals(s);
Function<String, String> noMapping= s -> s;
Function<String, Integer> mapToLength = s -> s.length();
List<String> uppers= mapViaFilter(list, filterUppers, noMapping);
List<Integer> lengthsOfUppers= mapViaFilter(list, filterUppers, mapToLength);
System.out.println(uppers);
System.out.println(lengthsOfUppers);
[Hello, Goodbye]
[5, 7]
지금까지 함수형 인터페이스 내 추상 메서드와 람다식을 연결시켜 보았다.
하지만 이 연결 시키는 과정에는 몇 가지 주의할 점이 존재한다.
이는 사실 당연한 소리이다. 만약 추상 메서드와 람다식의 Function Descriptor 가 맞지 않으면 컴파일 에러가 발생한다.
하지만 여러 개의 추상 메서드를 거쳐 람다식을 연결시킬 경우, 이를 헷갈리지 않도록 조심해야 한다. 다음 예시를 통해 Function Descriptor 가 어떻게 맞는지 확인해보자.
예시 : `list.sort(...)`(사실 해당 내용은 메서드 참조까지 알고 있어야 모두 이해할 수 있다.)
List<String> list = new ArrayList<>(
List.of("12345", "1234", "123", "12", "1")
);
list.sort(Comparator.comparing(String::length));
우선 list.sort(...) 메서드는 Comparator<? extends String> 타입의 매개변수가 필요하다.
public interface List<E> extends SequencedCollection<E> {
/* ... */
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}
}
또한 Comparator<T> 인터페이스는 int compare(T o1, T o2); 추상 메서드를 가진 함수형 인터페이스로, 결국 우리의 list.sort(...) 에 들어갈 ... 는 (String, String) -> int 로 표현할 수 있어야 한다.
Comparator.comparing(String::length) : (String, String) -> int 이어야 함.그 다음으로 Comparator.comparing 을 확인해 보면,
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
/* ... */
public static <T, U extends Comparable<? super U>>
Comparator<T> comparing(Function<? super T, ? extends U> keyExtractor)
{
Objects.requireNonNull(keyExtractor);
return (Comparator<T> & Serializable)
(c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}
}
이는 Function<? super T, ? extend U> 타입의 인자를 받고, Comparator<T> 를 반환하는 정적 메서드임을 알 수 있다. 그런데 Comparator<T> 는 (T, T) -> int 의 추상 메서드 int compare(T o1, T o2) 가 존재하므로, 이를 정리하면
Comparator.comparing : Function<T, U> -> Comparator<T>Function<T, U> : T -> UComparator<U> : (U, U) -> intComparator.comparing : T -> (U, U) -> int or T -> (T -> U) & (T -> U) -> int즉, 우리는 Comparator.comparing 메서드에 Function<T, U> 형 람다식으로 T 를 어떤 방식으로 순서를 매길지 정의하고, 이를 Comparator<U> 가 전달받아 int (순서) 로 바꿔주는 것이다.
이를 코드와 함께 나타내보면 다음과 같다.
// String.length method : String -> int
// Comparator.comparing(String::length) :
// String -> (String -> int) & (String -> int) -> int
list.sort(Comparator.comparing(String::length));
/* code above is equivalent with */
// String -> int
Function<String, Integer> mapToLength = s -> s.length();
list.sort(Comparator.comparing(mapToLength));
/* or */
// String -> int ⇘
// (int, int) -> int
// String -> int ⇗
Comparator<String> lengthComparator
= (s1, s2) -> mapToLength.apply(s1)
.compareTo(mapToLength.apply(s2));
// (String -> int).compareTo((String -> int))
list.sort(lengthComparator);
위 설명을 단계별로 정리하면 다음과 같다.
Comparator.comparing(String::length) : (String, String) -> int 이어야 함.Comparator.comparing 메서드는 Function<T, U> -> Comparator<T> 형태이므로, 결국T -> (U, U) -> int or T -> (T -> U) & (T -> U) -> int 형태로 정리 가능Comparator.comparing(String::length) 는Function<String, Integer> - String::length 를 이용해 List 내 문자열의 길이를 맵핑하고,Comparator 의 compare 메서드로 비교해,List 내 원소간 순서를 확정지어 정렬함.(?) -> void 형 람다결국 람다를 사용하려면 Function Descriptor 가 맞아야 한다. 하지만 void 반환형 람다의 경우 조금 애매해 질 수 있다. 다음 예시를 보자.
interface ReturnVoid {
void foo();
}
interface ReturnInt {
int bar();
}
String temp = "abcde";
ReturnVoid lambda1 = () -> temp.length();
ReturnInt lambda2 = () -> temp.length();
분명히 ReturnVoid 와 ReturnInt 의 Function Descriptor 가 다름에도 불구하고, 똑같이 생긴 람다식이 사용되었다.
이는 void 반환형 람다의 특징으로 인한 것으로, void 형 람다는 () -> ... 의 ... 가 return 된다고 보지 않는다.
때문에 이 둘의 람다를 더 풀어 적으면 다음과 같다.
ReturnVoid lambda1 = () -> {
temp.length();
};
ReturnInt lambda2 = () -> {
return temp.length();
};
얼핏 보면 람다식이 동일해 보여도 아닐 수 있고, 이를 주의해 람다식을 작성해야 한다는 것이다.
종종 람다식에서 자유 변수 (Free variable) 를 사용하는 것을 볼 수 있다.
자유 변수란, 람다식의 범위 (scope) 보다 바깥에 존재하는 변수를 뜻하며, 이는 지역변수, 인스턴스 변수, 정적 변수일 수 있다.
class CLS {
public int instanceVar;
public static int staticVar;
}
int localVar = 10;
람다식에서 인스턴스, 정적 변수를 사용하는 것은 아무런 문제가 되지 않는다. 하지만 지역변수는 제한이 존재한다.
람다식에서 지역변수를 이용하기 위해선, 해당 지역변수가 불변해야 한다.
int localVar = 10;
Predicate<Integer> useLocal = i -> i >= localVar;
localVar = 100; // compile error : local variables referenced from
// a lambda expression must be final or effectively final
왜 지역변수에만 이런 제한이 있을까?
이는 지역 변수는 Stack 메모리에 저장되고, 다른 이들은 Heap 에 저장되기 때문이다.
멀티 스레드 환경에서 생각해보자. 스레드 A, B 가 존재하고 A 의 지역변수를 이용해 람다식이 만들어졌다.
이 때 B 에서 람다식을 사용하려 했지만, A 에서 사용된 지역변수가 없어진 (deallocated) 상황을 생각해보자.
만약 위 상황에서 람다식 사용을 허용한다면, 이는 deallocated 된 메모리에 접근하는 것이고, 이는 프로그램 상 엄청난 오류이다.
또한 스레드 마다 개별적인 (침범하거나 침범될 수 없는) Stack 영역이 존재하기에, 다른 스레드의 Stack 영역을 참조하는 오류 또한 발생한다.
때문에 람다에 지역변수를 사용하기 위해선 이가 불변해야 하고, 만약 람다식에 지역변수가 사용되면 Java 는 그와 동일한 값을 복사해 어딘가 저장해 놓는다. (복사해서 결국 다른 스레드의 Stack 에 침범하는 일은 일어나지 않음)
final int localVar1 = 10;
int localVar2 = 20;
Predicate<Integer> useLocal1 = i -> i >= localVar1;
Predicate<Integer> useLocal2 = i -> i >= localVar2; // no compile error occurs
Functional InterfaceLambda Expressions, Approach 5: Specify Search Criteria Code with a Lambda Expression - Oracle Docs[1] : A functional interface is any interface that contains only one abstract method. (A functional interface may contain one or more default methods or static methods.) Because a functional interface contains only one abstract method, you can omit the name of that method when you implement it.Annotation Interface FunctionalInterface - Oracle Docs[2] : If a type is annotated with this annotation type, compilers are required to generate an error message unless:interface type and not an annotation type, enum, or class.annotated type satisfies the requirements of a functional interface.java.util.function[3] : There are several basic function shapes, including Function (unary function from T to R), Consumer (unary function from T to void), Predicate (unary function from T to boolean), and Supplier (nullary function to R).