1996년 처음 공개된 이후 자바에는 괄목할 만한 두 번의 변화가 있었다. 그 첫 번째가 '지네릭스(Generics)'의 등장이었고, 두 번째가 바로 '람다식(Lambda Expression)'의 등장이다. 특히 람다 표현식의 지원으로 자바는 객체지향 언어의 특징과 함께 함수형 언어의 특성을 갖추게 되었다.
람다식(Lambda expression)
람다식은 1930년대 알론조 처치(Alonzo Church)라는 수학자가 처음 제시한 함수의 수학적 표기방식인 람다 대수에 그 뿌리를 두고 있다. 람다식을 이용하면 코드가 간결해지고, 지연 연산 등을 통해서 퍼포먼스를 향상을 도모할 수 있는 장점을 얻을 수 있다. 반면 모든 엘리먼트를 순회하는 경우에는 성능이 떨어질 수도 있고, 코드를 분석하기 어려워 질 수 있다는 단점도 존재한다.
자바에서의 람다식은 '메서드를 하나의 식으로 표현한 것'이다. 두 정수의 합을 구해주는 다음 메소드를 생각해보자.
public int sum(int a, int b) {
return a + b;
}
이 메소드를 사용하기 위해서는 클래스를 만들고 객체를 생성해야 한다. 'static' 키워드를 붙여서 객체 생성을 우회할 수도 있지만 결국 클래스를 정의해야 함은 변함이 없다.
위 메서드를 람다식으로 표현해보면 다음과 같다.
(a, b) -> a + b;
매우 깔끔하고 알아보기도 쉽다. 게다가 이 동작을 사용하기 위해서 클래스를 정의할 필요도 없다. 메소드의 리턴 타입도 없고, 메소드의 이름도 없다. 이 때문에 람다식을 '익명함수(Anonymous Function)'라고 부르기도 한다. 메소드의 이름도 리턴 타입도 없지만 뭐하는 식인지는 쉽게 알 수 있다.
사실 자바의 람다는 익명 클래스와 같다. 위 람다식은 다음의 익명 클래스와 같다.
new Object() {
int sum(int a, int b) {
return a + b;
}
}
람다식으로 작성된 함수는 자바 컴파일러가 익명 클래스처럼 해석을 해준다. 람다식이 곧 익명 클래스 객체이기 때문에 다른 메소드의 인자로 일반 객체를 넘기듯이 람다를 넘겨줄 수 있다. 또 한 메소드의 리턴 값으로 람다를 넘겨 받을 수도 있다. 함수형 프로그래밍의 고계함수(High-order Function)를 자바에서는 익명 클래스를 통해서 자연스럽게 지원하고 있다.
람다식 문법(Lambda expression syntax)
자바에서의 람다식은 다음과 같은 문법으로 사용할 수 있다.
(파라미터 목록) -> { 람다식 바디 }
람다식의 시작 부분에는 파라미터들을 명시할 수 있다. 비교적 엄격한 타입 제한을 두고 있는 자바지만 람다의 파라미터를 추론할 수 있는 경우에는 타입을 생략할 수도 있다. 위에서 봤던 '(a, b) -> a + b' 람다식에서도 파라미터인 a, b 의 타입이 명시되지 않았다. 추론가능한 타입을 생략하면서 간결함을 얻었다. (추론할 수 없는 경우에는 메소드 파라미터처럼 타입도 명시해줘야한다.)
파라미터가 하나인 경우 괄호를 생략 할 수도 있다. 제곱을 구하는 람다식을 다음과 같이 작성할 수 있다.
a -> a * a
하나의 파라미터만 필요하기 때문에 괄호를 생략했다.
람다식의 바디가 하나의 표현식인 경우에는 중괄호를 생략할 수 있다. 위에서 봤던 람다들은 하나의 식으로 구성되어 있기 때문에 중괄호를 생략했다. 이 때, 세미콜론은 붙이지 않는다.
다만, 라다식의 바디에 'return' 문이 있을 경우 중괄호를 생략할 수 없다.
(a, b) -> { return a > b ? a : b }
이런 람다식을
(a, b) -> return a > b ? a : b
이렇게 바꾸면 에러가 발생한다.
중괄호를 쓰고 싶지 않으면,
(a, b) -> a > b ? a : b
이렇게 하나의 표현식으로 써주면 된다.
예제) 람다를 이용한 Runnable 구현
멀티 쓰레드 프로그래밍에서 코드의 일부분을 새로운 스레드로 분리, 실행 시킬 수 있다. 이 때 실행시킬 코드의 일부분은 Runnable 인터페이스를 구현한 클래스로 구현할 수 있는데, 대부분 익명 객체를 구현해 사용한다.
예를 들어
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Start Thread");
Thread.sleep(1000);
System.out.println("End Thread");
}
});
이렇게 사용한다.
이 코드를 람다를 사용해서 구현하면 다음과 같다.
Thread thread = new Thread(() -> {
System.out.println("Start Thread");
Thread.sleep(1000);
System.out.println("End Thread");
});
예제) 람다를 이용한 컬렉션 순회
많은 경우 자바에서 람다는 컬렉션과 같이 사용된다. LisT<> 인터페이스에 추가된 forEach() 메소드를 이용하면 리스트 컬렉션에 들어있는 각 엘리먼트에 입력받은 람다식을 수행할 수 있다.
다음 코드를 살펴보자.
List<String> list = new ArrayList();
list.add("Element1");
list.add("Element2");
list.add("Element3");
list.forEach(x -> System.out.println(x))
// 위 코드는 list.forEach(System.out::println) 으로 축약할 수 있음
리스트의 각 엘리먼트를 순회하면서 System.out.println()을 호출하는 코드가 굉장히 간단해졌다.
함수형 인터페이스 (Functional Interface)
람다식은 익명 객체라고 했다. 익명 객체는 메소드의 인자로 넘겨 줄 수도 있고, 메소드의 리턴 값으로 넘겨 받을 수도 있다. 따라서 익명 객체인 람다를 다룰 수 있는 인터페이스가 필요하다. (Object 타입으로 람다를 다루기에 람다 관련 기능을 Object 클래스에 넣어야 하는 부담이 있었다)
람다식을 저장할 수 있는 변수는 '함수형 인터페이스(functional interface)' 타입이어야 한다.
FunctionalInterface myLambda = (a, b) -> a + b;
함수형 인터페이스는 하나의 추상 메소드만을 갖는 인터페이스다. (이 때, static 메소드와 default 메소드의 개수에는 제약이 없다.) 그 추상 메소드의 시그니처(파라미터 개수와 타입, 리턴 타입)와 동일한 시그니처를 갖는 람다를 할당해서 사용할 수 있다.
함수형 인테페이스 정의
함수형 인터페이스는 기본적으로 인터페이스다. 여기에 "@FunctionalInterface" 애너테이션을 인터페이스 상단에 붙여주면 자바 컴파일러가 함수형 인터페이스를 올바르게 정의하였는지 확인해준다.
예를 들어
@FunctinalInterface
interface MySum {
public int sum(int a, int b);
}
이런 인터페이스를 정의하면 두 숫자를 더하는 람다를 다음과 같이 사용할 수 있다.
public static void main(String []args) {
MySum func = (a, b) -> a + b;
System.out.println(func.sum(10, 11));
}
이 코드를 실행하면 21이라는 숫자가 출력된다.
java.util.function 패키지
자바에서는 자주 사용되는 함수형 인터페이스들을 미리 정의해서 'java.util.function' 패키지로 만들어 놨다. 매번 새로운 함수형 인터페이스를 정의하지 말고, 이 패키지에 정의된 인터페이스를 사용하는 것이 좋다. 그래야 함수형 인터페이스를 사용하는 소스코드의 재사용성이나 유지보수적인 측면에서 좋다.
가장 기본적인 함수형 인터페이스는 다음과 같다.
함수형 인터페이스 | 메서드 |
java.lang.Runnable | void run(); |
Supplier<T> | T get(); |
Consumer<T> | void accept(T t); |
Function<T, R> | R apply (T t); |
Predicate<T> | boolean test(T t); |
파라미터가 두 개인 함수형 인터페이스는 다음과 같다.
함수형 인터페이스 | 메서드 |
BiConsumer<T, U> | void accept(T t, U u); |
BiPredicate<T, U> | boolean test(T t, U u); |
BiFunction<T, U, R> | R apply(T t, U u); |
파라미터가 두 개 이상인 함수형 인터페이스를 사용하고 싶은 경우엔 직접 만들어 사용해야 한다. 대부분은 하나 혹은 두개의 파라미터를 갖고 있기 때문에 이 들만 정의 되어 있다.
하나의 파라미터를 받고 동일한 타입을 리턴하는 함수형 인터페이스들도 있다.
함수형 인터페이스 | 메서드 |
UnaryOperator<T> | T apply(T t); |
BinaryOperator<T> | T apply(T t1, T t2); |
지네릭을 사용하는 함수형 인터페이스는 기본형(Primitive Type)을 사용할 때, 래퍼(Wrapper) 클래스를 사용해야하는 비효율이 있었다. 따라서 기본형(Primitive Type)을 사용하는 함수형 인터페이스들도 제공된다.
함수형 인터페이스 | 메서드 |
IntFunction<R>, LongFunction<R>, DoubleFunction<R> | R apply(int value), R apply(long value), R apply(double value) |
ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T> | int applyAsInt(T t), long applyAsLong(T t), double applyAsDouble(T t) |
함수형 인터페이스의 이름을 살펴보면 어떤 기본 타입과 연관되어있는지 쉽게 알 수 있다. 이 밖에 IntToLongFunction, DoubleToIntFunction, ObjIntConsumer<T> 등의 함수형 인터페이스도 존재한다. 이들은 이름만 잘 살펴보면 쉽게 사용방법을 알 수 있다.
마지막으로 컬렉션과 함께 사용할 수 있는 함수형 인터페이스를 살펴보겠다.
인터페이스 | 메서드 | 설명 |
Collection | boolean removeIf(Predicate<E> filter); | 조건에 맞는 엘리먼트를 삭제 |
List | void replaceAll(UnaryOperator<E> operator); | 모든 엘리먼트에 operator를 적용하여 대체(replace) |
Iterable | void forEach(Consumer<T> action); | 모든 엘리먼트에 action 수행 |
Map | V compute(K key, BiFunction<K, V, V> f); | 지정된 키에 해당하는 값에 f를 수행 |
Map | V computeIfAbsent(K key, Function<K, V> f); | 지정된 키가 없으면 f 수행후 추가 |
Map | V cumputeIfPresent(K key, BiFunction<K, V, V> f) | 지정된 키가 있을 때, f 수행 |
Map | V merge(K key, V value, BiFunction<V, V, V> f); | 모든 엘리먼트에 Merge 작업 수행, 키에 해당하는 값이 있으면 f 수행해서 병합후 할당 |
Map | void forEach(BiConsumer<K, V> action); | 모든 엘리먼트에 action 수행 |
Map | void replaceAll(BiFunction<K, V, V> f); | 모든 엘리먼트에 f 수행후 대체 |
이 패키지에 정의된 함수형 인터페이스만 잘 써도 큰 어려움은 없을 것이다.
Function의 합성
위에서 소개한 함수형 인터페이스에는 하나의 추상 메소드 이외에도 디폴트 메소드와 static 메소드가 정의되어 있다. 특히 Function 인터페이스와 Predicate 인터페이스에는 재미있는 디폴트 메소드들이 있다.
학교 수학 시간에 함수의 합성에 대해서 들어본 적이 있을 것이다. f(x) 함수와 g(x) 함수가 있을 때, 이 두 함수를 연결하여 f(g(x)) 라는 합성 함수를 만들어 낼 수 있다. g(x)의 결과를 다시 f(x) 함수의 인자로 넣어주는 것이다.
Function 인터페이스에는 이런 함수 합성을 지원하는 디폴트 메소드가 있다.
default <V> Function <T, V> andThen (Function <? super R, ? extends V> after); |
default <V> Function <V, R> compose(Function <? super V, ? extends T> before); |
static <T> Function<T, T> identity(); |
f.andThen(g) 를 수행하면 f 함수를 실행한 결과 값을 다시 g 함수의 인자로 전달하여 결과를 얻는 새로운 함수를 만들어 내게 된다. 이 때, f 함수의 리턴 타입이 g 함수의 파라미터 타입과 호환되어야 한다. 반대로 f.compose(g) 를 수행하면 g 함수를 먼저 적용하고 f를 나중에 적용하는 함수를 만들어 내게 된다. 마찬가지로 먼저 적용되는 함수의 리턴 타입과 나중에 적용되는 함수의 파라미터 타입이 맞아야 한다.
identity() 메소드는 "항등함수"를 만들어 낼 때 사용된다. 항등함수는 잘 사용되는 편은 아니며 스트림의 map()으로 변환 작업할 때 변환 없이 그대로 처리할 때 사용된다.
Predicate 결합
Predicate 함수형 인터페이스는 boolean 값을 리턴하는 함수를 다룬다. 따라서 여러개의 Predicate을 논리 연산자(&&, ||, !)를 이용해 연결해서 하나의 Predicate으로 얻어 낼 수도 있다. 이를 Predicate 결합이라고 한다.
Prediate을 결합할 때 사용하는 디폴트 메소드는 다음과 같다.
default Predicate<T> and (Predicate<? super T> other) ; |
default Predicate<T> or (Predicate<? super T> other); |
default Predicate<T> negate (); |
static <T> Predicate<T> isEqual(Object targetRef); |
예를 들어서
Predicate <Integer> greater = x -> x > 10;
Predicate <Integer> less = x -> x < 20;
이런 Predicate 이 있을 때
Predicate between = greater.and(less);
이런 식으로 Predicate을 결합하면 10 < x < 20 인지를 판단하는 Predicate 함수를 얻어낼 수 있다. or() 메소드와 negate() 메소드도 비슷하게 사용하면 된다.
isEqual() 메소드는 인자로 받은 객체와 같은지 판단해주는 새로운 함수를 만들어 준다. 예를 들어
String nation = "korea"
Predicate<String> checkKorea = Predicate.isEqual(nation);
boolean isKorea = checkKorea.test(newNation);
isEqual() 메소드를 이용해서 만들어진 함수는 "korea"라는 문자열과 같은지 판단해주는 함수가 된다.
메소드 참조
익명 객체의 메서드를 간결하게 표현해 주는 람다를 더 간결하게 표현할 수 있다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
이런 람다의 경우 하나의 인자와 하나의 식으로 구성된 굉장히 간단한 형태다. 이런 메소드 호출의 경우 메소드 참조를 이용해서 다음과 같이 더욱 간결하게 줄일 수 있다.
Function<String, Integer> f = Integer :: parseInt;
람다식의 문법에서 많은 부분이 생략되었지만 자바 컴파일러는 함수형 인터페이스의 지네릭 타입으로부터 원래 형태의 람다를 유추해낼 수 있다. 따라서 소스코드를 더욱 간결하게 작성할 수 있게 된 것이다.
두 개의 인자를 입력으로 받는 BiFunction의 경우를 생각해보자.
BiFunction<String, String, Boolean> f = (s1 , s2) -> s1.equals(s2);
이런 함수는
BiFunction<String, String, Boolean> f = String::equals;
이렇게 간단히 바꿀 수 있다. 사용하다보면 손에 익게 되고 IDE 에서 더 간단하게 쓸 수 있다고 체크해주기도 한다.
마지막으로 이미 생성된 객체를 람다식에서 사용한 경우를 생각해보자.
Function<String, Boolean> f = x -> obj.equals(x);
위 람다식은
Function <String, Boolean> f = obj :: equals;
이렇게 줄여서 쓸 수 있다.
생성자의 메소드 참조
객체를 생성하는 생성자의 경우에도 메소드 참조로 변환할 수 있다. 앞에서 봤던 미리정의된 함수형 인터페이스인 Supplier를 사용하는 예제를 보자.
Supplier <TestClass> s = () -> new TestClass();
Supplier 함수를 실행시키면 새로운 TestClass 객체를 만들어 리턴해준다. 이 람다는 다음과 같이 축약해서 사용할 수 있다.
Supplier <TestClass> s = TestClass::new;
하나의 인자를 받는 생성자의 경우를 생각해보자. 다음 두 함수형 인터페이스는 동일하다.
Function<Integer, TestClass> f = (i) -> new TestClass(i);
Function<Integer, TestClass> f = TestClass::new;
두개의 인자를 받는 생성자의 경우도 마찬가지다.
BiFunction<Integer, String, TestClass> bf = (i, s) -> new TestClass(i, s);
BiFunction<Integer, String, TestClass> bf = TestClass::new;
배열을 생성할 때를 생각해보자. 다음 두 코드는 동일하다.
Function<Integer, int[]> f = x-> new int[x];
Function<Integer, int[]> f = int[] :: new;
자바가 람다와 함수형 인터페이스를 지원하면서 함수형 프로그래밍을 어느정도 지원하기 시작했다. 물론 동일한 동작을 하는 코드를 전통적인 방법으로 작성할 수도 있다. 하지만 람다의 간결함을 이용하여 좀 더 유지보수하기 좋은 코드를 작성하는 것도 의미 있다.
변수 범위
자바에서 람다를 사용할 때 변수의 범위(scope)와 관련해서 알아둬야 할 것들이 있다. 다음 코드를 살펴보자.
import java.util.function.Function;
public class Example {
public static Function<String, String> getFunction(String str) {
return (x) -> x.concat(str);
}
public static void main(String []args) {
Function<String, String> getConcat = getFunction("_suffix");
System.out.println(getConcat.apply("name"));
}
}
}
getDouble()이라는 메소드를 실행하면 인자로 준 문자열을 뒤에다 붙여주는 람다를 리턴해준다. main 메소드를 실행시켜보면 다음 결과를 얻을 수 있다.
name_suffix
만약 getFunction 함수내에서 str 변수를 수정하려고 하면 "Variable used in lambda expression should be final or effectively final" 라는 에러를 발생시킨다.
람다 정의에서 파라미터로 받은 대상이 아닌 외부 변수를 참조하는 경우, 외부 변수는 final 이거나 'effectively final' 이어야 한다는 의미다. 즉, final로 선언되어 변하지 않음이 보장되어야 람다 내부에서 사용할 수 있다는 의미다.
effectively final
오라클 문서에 'effectively final'은 다음과 같이 정의되어 있다.
"A variable or parameter whose value is never changed after it is initialized is effectively final"
final 키워드를 붙여서 선언하지는 않았지만 초기화 된 이후로 값의 할당이 변경되지 않는 변수를 'effectively final'이라고 하는 모양이다. 키워드를 붙이지 않아도 코드 패스에서 변하지 않는 변수는 자바 컴파일러가 final로 간주하여 람다 내부에서 쓸 수 있게 허용하는 모양.
변수의 섀도잉 (Variable Shadowing)
https://en.wikipedia.org/wiki/Variable_shadowing
다음 코드를 살펴보자.
public static class Example {
int number = 10;
public Supplier<Integer> getFunction1() {
return () -> number * 10;
}
public Supplier<Integer> getFunction2(int number) {
return () -> number * 10;
}
public Supplier<Integer> getFunction3() {
int number = 20;
return () -> number * 10;
}
}
getFunction1()로 얻어진 함수는 100을 리턴하고, getFunction2()는 인자로 주어진 값에 10을 곱한 값, getFunction3()은 200을 리턴한다. 각 메소드에서 리턴하는 함수가 참조하는 number 변수는 마찬가지로 effectively final 이어야 한다.
this 키워드
람다의 몸통에서 this 키워드를 사용하는 경우를 생각해보자.
public class Example {
private String name = "Dave";
public Supplier<String> getNameFunction() {
return () -> this.name;
}
}
getNameFunction() 메소드로 얻어진 함수는 "Dave"라는 문자열을 리턴한다. 그렇다면 다음 코드는 어떨까?
public class Example {
private String name = "Dave";
public Supplier<String> getNameFunction() {
Supplier<String> getNamer = new Supplier<String>() {
@Override
public String get() {
return this.name;
}
};
return getNamer;
}
}
이 코드는 컴파일 에러가 발생한다. this.name 이라는 변수를 찾을 수 없기 때문이다. 람다만의 특징은 아니지만 함수형 인터페이스를 사용할 때 자주 볼 수 있는 패턴이기 때문에 설명하고 넘어가자.
컴파일 에러를 해결하기 위해서는 this.name을 Example.this.name으로 바꿔야 한다.
public class Example {
private String name = "Dave";
public Supplier<String> getNameFunction() {
Supplier<String> getNamer = new Supplier<String>() {
@Override
public String get() {
return Example.this.name;
}
};
return getNamer;
}
}
Supplier<String> 함수를 구현한 익명 클래스에서의 this는 익명 클래스 자체를 의미하기 때문에 name이라는 멤버가 존재하지 않는 것이다. 익명 클래스가 정의된 클래스의 멤버를 접근하려면 Example.this 처럼 클래스 이름을 주고, 그 뒤에 this를 붙여줘야 한다.
댓글