참고자료
- https://www.geeksforgeeks.org/lambda-expressions-java-8/
- https://www.javatpoint.com/java-lambda-expressions
- https://livebook.manning.com/book/java-8-in-action/appendix-d/41
- http://cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html
- https://www.geeksforgeeks.org/java-lambda-expression-variable-capturing-with-examples/
- https://velog.io/@sdb016/Variable-Capture
람다식은 자바8에 도입된 ‘하나의 메소드를 가진 인터페이스’를 쉽게 나타낼 수 있는 방법이다. 람다식은 특히 collection
라이브러리에서 유용하다. collection
에서 반복, 필터링, 데이터 추출을 도와준다.
람다식은 함수형 인터페이스를 가지고 있는 인터페이스를 구현하는데 사용된다. 람다식을 사용하므로서 많은 코드를 줄일 수 있다.
public class EX1{
public static void main(String[] args) {
int width = 10;
// 람다식을 사용하지 않을 경우
Drawable d = new Drawable() {
@Override
public void draw() {
System.out.println("Drawing " + width);
}
};
d.draw();
// 람다식을 사용할 경우
Drawable d2 = ()-> System.out.println("Drawing " + width);
d2.draw();
}
}
interface Drawable{
public void draw();
}
public class EX4_anonymous_class {
public static Animal cat = new Animal(){
@Override
public void eat(){
System.out.println("eat fish!");
}
};
public static void main(String[] args) {
cat.eat();
Animal dog = new Animal(){
@Override
public void eat(){
System.out.println("eat meat!");
}
};
dog.eat();
Bug bug = new Bug() {
@Override
public void eat() {
System.out.println("eat bug!");
}
};
bug.eat();
}
}
class Animal{
public void eat(){
System.out.println("eat!");
}
}
interface Bug{
void eat();
}
익명 클래스란 이름이 없는 클래스를 말한다. 이름이 없기 때문에 단발적으로만 사용할 때 정의한다. 익명 클래스를 구현하는 방식은 두 가지 이다. 첫 번째는 부모 클래스를 상속하는 자식 클래스를 익명 클래스로 정의하는 것이며, 두 번째는 인터페이스를 구현하는 것이다. 위 예제에서 Animal
이 전자이고, Bug
가 후자이다.
(argument-list) -> {body}
람다식을 세 가지의 컴포넌트를 갖는다.
람다식은 반드시 함수형 인터페이스를 구현하는 방식으로 사용 가능하다. 즉, 람다식을 통해 함수를 만들었다고 해도, 해당 함수는 추상 메소드를 구현하는 방식으로 사용되어야 한다.
public class EX4_lambda_use {
public static void main(String[] args) {
Calculate c1 = (a,b)-> a+b;
Calculate c2 = (a,b) ->{return a+b;};
Calculate c3 = Integer::sum;
System.out.println(c1.calculate(10,20));
System.out.println(c2.calculate(10,20));
System.out.println(c3.calculate(10,20));
}
}
interface Calculate{
int calculate(int a, int b);
}
c1,c2,c3 는 모두 동일한 동작을 수행한다. 셋 다 람다식으로 표현한 것이다. 이 중 Calculate c3 = Integer::sum
이 의아해 보일 수 있다. 나도 처음에 저게 뭔가 싶었다. 바이트 코드를 살펴보니
INVOKEDYNAMIC calculate()Lcom/example/Calculate; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
(II)I,
// handle kind 0x6 : INVOKESTATIC
java/lang/Integer.sum(II)I,
(II)I
]
일반적인 람다식의 바이트코드와 마찬가지의 형태를 보이지만, 추상 메소드를 호출할 때, 이번에 컴파일 하면서 생성한
private static synthetic lambda$main$0(II)I
L0
LINENUMBER 7 L0
ILOAD 0
ILOAD 1
IADD
IRETURN
L1
LOCALVARIABLE a I L0 L1 0
LOCALVARIABLE b I L0 L1 1
MAXSTACK = 2
MAXLOCALS = 2
위와 같은 static 메소드를 호출하지 않고, java.lang.Integer::sum
을 호출하고 있다. 함수형 인터페이스에 대해 람다식을 통해 구현하지 않고, 다른 클래스의 함수를 사용할 경우, 추상 메소드 호출 시 다른 클래스의 함수를 호출하는 것으로 유추된다.
public class EX5_lambda_byte_code {
public static void main(String[] args) {
Functional f = ()->{};
}
}
interface Functional{
void abstractMethod();
}
람다식이 어떠한 방식으로 동작하는지 자세히 알기 위해 위와 같이 간단한 코드를 컴파일하였다.
public class com/example/EX5_lambda_byte_code {
// compiled from: EX5_lambda_byte_code.java
// access flags 0x19
public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup
// access flags 0x1
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/example/EX5_lambda_byte_code; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
// access flags 0x9
public static main([Ljava/lang/String;)V
L0
LINENUMBER 5 L0
INVOKEDYNAMIC abstractMethod()Lcom/example/Functional; [
// handle kind 0x6 : INVOKESTATIC
java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
// arguments:
()V,
// handle kind 0x6 : INVOKESTATIC
com/example/EX5_lambda_byte_code.lambda$main$0()V,
()V
]
ASTORE 1
L1
LINENUMBER 6 L1
RETURN
L2
LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
LOCALVARIABLE f Lcom/example/Functional; L1 L2 1
MAXSTACK = 1
MAXLOCALS = 2
// access flags 0x100A
private static synthetic lambda$main$0()V
L0
LINENUMBER 5 L0
RETURN
MAXSTACK = 0
MAXLOCALS = 0
}
위 바이트코드에서 잘 모르겠는 부분은 다음과 같다. 하나씩 알아가 보겠다.
INVOKEDYNAMIC abstractMethod()Lcom/example/Functional;
invokespecial
명령어를 사용한다.java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang ...
bootstrap 영역의 lambdafactory.metafactory
를 호출한다. 해당 메소드에서 CallSite
객체를 생성하는데, 어떤 방법으로 객체를 생성할지는 동적으로 결정된다.private static synthetic lambda$main$0()V
Invokedynamic(INDY)
INDY는 JVM이 동적 타입 언어를 지원하도록 하기위해 추가된 기능이다. 자바7에서 처음으로 릴리즈되었다. 비록 INDY가 동적 타입 언어를 지원하지만, 동적인 형태를 필요로 하는 언어에도 도움을 준다. 자바는 정적 타입 언어인데도 불구하고 자바8의 람다식은 INDY를 사용하여 구현되었다.
JVM은 이전부터 4가지 메소드 호출 타입을 지원하였다.
invokestatic
: 스태틱 메소드 호출invokeinterface
: 인터페이스 메소드 호출invokespecial
: 생성자 or super() or private 메소드 호출invokevirtual
: 인스턴스 메소드 호출위 메소드 호출 타입은 컴파일 타임에 어떤 메소드가 호출되는지 알고 있어야 한다. 하지만, INDY는 컴파일 타임에 어떤 메소드가 호출될지 알 수 없으며, 런타임에 메소드가 정해진다.
JVM이 처음 invokedynamic
명령어를 만나게 되면, 부트스트랩 메소드를 호출한다. 해당 메소드는 java.lang.invoke.CallSite
의 인스턴스를 반환한다. CallSite
는 실제 메소드에 대한 래퍼런스를 가지고 있다. JVM이 invokedynamic
명령어를 다시 만나게 되면, 위 과정들을 생략하고 곧바로 target method를 호출한다. 다만, 어떤 변화가 없다는 가정하에 말이다.
bootstrap method
CallSite
를 초기화하는 메소드이다. INDY는 JVM에서 임의의 함수를 호출하는데 사용되는 바이트코드 명령어이다. 컴파일 타임에는 어떤 함수가 호출되는 지 알 수 없다. 따라서, 특정 함수 대신에 CallSite
라는 클래스의 객체를 사용한다.
자바에서 함수가 독립적으로 존재할 수는 없다. 함수는 반드시 클래스나 인터페이스에 속하게 된다. 따라서, 함수를 호출하려면 해당 클래스나 객체를 사용해야 한다.
함수형 인터페이스는 함수를 독립적으로 나타내기 위해 고안되었다. 함수형 인터페이스는 하나의 추상 메소드를 가진 인터페이스를 말한다. 오직 하나의 추상 메소드만 가지고 있어야 한다. defalut 메소드는 여러 개 가지고 있어도 된다. 람다식은 이러한 함수형 인터페이스의 인스턴스를 나타내기 위해 등장하였다. 즉, 함수형 인터페이스와 람다식의 조합으로, 함수가 마치 독립적으로 존재하는 것처럼 사용할 수 있다.
위 어노테이션은 함수형 인터페이스가 오직 하나의 추상 메소드만 갖도록 보장한다. 그러나 위 어노테이션을 의무적으로 사용해야 되는 것은 아니다.
자바8에서는 네 가지 주요한 종류의 함수형 인터페이스를 포함하고 있다. 아래 함수형 인터페이스들은 다양한 상황에 적용될 수 있다.
Consumer 인터페이스는 하나의 아규먼트를 받고, 리턴은 하지 않는다. 말 그대로 매개변수를 소비만 하고 공급은 하지 않음을 의미한다.
Consumer<String> consumer = (value) -> System.out.println(value);
consumer.accept("hello world");
// hello world
람다식을 통해 함수형 인터페이스의 인스턴스를 생성한다. 람다식으로 표현된 함수는 accept()
를 구현한 것이며, accept()
를 호출하여 함수를 호출할 수 있다.
Consumer<String> consumer = (value) -> System.out.println(value);
List<String> list = Arrays.asList("apple","banana","orange");
list.forEach(consumer);
// apple
// banana
// orange
배열을 순회하며 각 엘리먼트에 대해 함수를 호출하고 싶을 때 함수형 인터페이스가 유용하게 사용된다.
Consumer<StringBuilder> print = (value) -> System.out.println(value.toString());
Consumer<StringBuilder> addString = (value) -> value.append(" world");
addString.andThen(print).accept(new StringBuilder("hello"));
// hello world
Consumer의 default method인 andthen
을 사용하면, Consumer를 체이닝할 수 있다. 이때 처음 매개변수로 받은 value가 다음 Consumer로 전달된다. 다만, primitive type은 초기 값이 전달 되므로 유의해야 한다.
Predicate는 매개변수를 받아 boolean 값을 반환한다. 즉, 매개변수를 보고 이에 대한 True or False를 결정하는 것이다. Predicate의 구현체는 filtering 로직에서 사용된다.
Predicate<Integer> predicate = (value) -> value >0;
List<Integer> list = Arrays.asList(-2,-1, 0, 1, 2);
list.stream().filter(predicate).forEach((value)-> System.out.println(value));
// 1
// 2
Function은 하나의 매개변수를 받고 값을 반환한다. primitive type은 general한 타입의 매개변수로 받을 수 없기 때문에, 이를 지원하기 위해 Function에는 수많은 버전들이 존재한다. 필요에 따라 해당하는 인터페이스를 사용하면 된다.
Function<String, String> function = (value) -> value + " addValue";
System.out.println(function.apply("Default String"));
// Default String addValue
Supplier는 매개변수를 받지 않고, 하나의 값만 반환한다. 공급은 하지만, 소비는 하지 않는다.
Supplier<String> supplierStr = () -> "supply String!";
System.out.println(supplierStr.get());
람다는 자유 변수를 참조할 때 직접 그 변수를 참조하지 않고, 자유 변수를 자신의 stack에 복사하여 참조한다. 이를 variable capture
라고 말한다.
람다식을 감싸고 있는 대상의 변수들에 대해 람다식에서 접근하는 것이 가능하다. 예를 들어, 람다식은 람다식의 감싸고 있는 클래스의 인스턴스와 스태틱 변수를 사용할 수 있다. 또한, 메소드를 호출할 수 있다.
그런데 만약, 람다식이 그것을 감싸고 있는 대상에 있는 로컬 변수를 사용하면 variable capture
와 관련한 문제가 발생할 수 있다. 이를 위해 람다식에서 사용하는 자유 변수는 아래 두 가지 제약 조건을 따라야 한다.
public class EX6_variable_capture {
public static void main(String[] args) {
int number = 10;
MyFunction myLambda = (n)->{
int value = number + n;
return value;
};
number = 9;
System.out.println("Test");
}
}
interface MyFunction{
int func(int n);
}
// 출력
// java: local variables referenced from a lambda expression must be final or effectively final
위 코드는 제약조건을 어겼기 때문에 컴파일 에러가 발생한다.
public class EX7_variable_capture2 {
int data = 170;
public static void main(String[] args) {
EX7_variable_capture2 test = new EX7_variable_capture2();
MyInterface myInterface = ()->{
System.out.println("Data : " + test.data);
test.data += 500;
System.out.println("Data : " + test.data);
};
myInterface.myFunction();
test.data += 200;
System.out.println("Data : " + test.data);
}
}
interface MyInterface{
void myFunction();
}
// Data : 170
// Data : 670
// Data : 870
위 코드는 제약조건을 어긴 것처럼 보이지만, 정상적으로 동작한다. 왜냐하면 객체의 멤버 변수의 값이 수정되었지만, 객체를 가리키는 래퍼런스 변수는 수정되지 않았기 때문이다(effectively final). 만약, 래퍼런스 변수를 수정한다면 컴파일 에러가 발생할 것이다.
public class EX7_variable_capture_method_reference {
public static void main(String[] args) {
TestInterface retInterface = TestClass.func2();
retInterface.func(10);
TestClass.addData(20);
retInterface.func(0);
}
}
class TestClass{
int data = 10;
public static TestClass instance = new TestClass();
public static TestInterface func2(){
TestInterface testInterface = (n)-> {
instance.data += 10;
System.out.println("Data : " + instance.data);
};
testInterface.func(10);
return testInterface;
}
public static void addData(int n){
instance.data += 10;
}
}
interface TestInterface{
void func(int n);
}
아래 바이트코드는 위 코드 중 람다식 부분이다. 람다식에서는 static 변수를 사용하고 있다. 이에 따라, 바이트코드에서도 클래스의 static 변수를 가져오는 것을 확인할 수 있었다.
private static synthetic lambda$func2$0(I)V
L0
LINENUMBER 20 L0
GETSTATIC com/example/TestClass.instance : Lcom/example/TestClass;
DUP
GETFIELD com/example/TestClass.data : I
BIPUSH 10
IADD
PUTFIELD com/example/TestClass.data : I
variable capture 값은 어디에 저장될까?
그렇다면, capture된 값은 어디에 저장되는 것일까? 자유 변수를 람다식 내부에서 사용하면, 이후 람다식의 객체를 재사용할 경우 초기 자유 변수의 값이 유지되는 것을 알 수 있다. 마치 람다식이 구현한 인터페이스 객체에 final 변수가 있는 것처럼 말이다. 구글을 엄청 뒤져봤지만, variable capture된 값이 stack에 저장된다고만 있을 뿐, 람다식 재사용시 해당 값이 어디에 저장되는 지는 찾지 못했다. 그래서, 인터페이스의 구현체에 final로 담겨있다고 유추하였다.
이 부분은 위에 있는 예제 코드와 중복되어 생략한다.