자바 8과 C#의 람다(Lambda) 지원에 대한 비교
자바가 드디어 람다 지원을 하게 되었군요. ^^
그런데, 개인적으로 C#과 어떤 부분이 다를까 궁금해서 좀 찾아봤습니다. 이를 위해 자바 8 개발환경을 구성해야 하는데, 다음과 같은 준비가 필요합니다.
- JDK/JRE 8 설치
- Eclipse Kepler SR2 설치
- Eclipse 내의 Marketplace에서 "Java 8 support for Eclipse Kepler SR2" 플러그인 설치
구성이 끝났으면, 이제 ^^ 간단한 것부터 한번 테스트 해볼까요? ^^
package testapp;
public class Lambda {
public static void main(String[] args) {
new Thread(() -> { System.out.println("Hello World!"); }).start();
}
}
오~~~! C#의 람다 문법과 비슷해서 C# 개발자의 경우 직관적으로 사용할 정도입니다. 위의 코드를 C#으로 옮기면 그 유사성을 실감할 수 있습니다.
using System;
using System.Threading;
class Program
{
static void Main(string[] args)
{
new Thread(() => { Console.WriteLine("Hello World!"); }).Start();
}
}
단지 화살표 하나냐(->), 두개냐(=>)의 차이일 뿐입니다. 람다 내부의 코드가 한 줄인 경우 자바나 C#이나 괄호({, })를 생략할 수 있다는 점도 동일합니다.
package testapp;
public class Lambda {
public static void main(String[] args) {
new Thread(() -> System.out.println("Hello World!") ).start();
}
}
using System;
using System.Threading;
class Program
{
static void Main(string[] args)
{
new Thread(() => Console.WriteLine("Hello World!") ).Start();
}
}
C#의 경우, 메서드를 받을 수 있는 delegate 타입이 있기 때문에 람다 구현이 자연스럽지만, 듣기로는 JVM의 경우 delegate 타입에 대한 확장이 (현실적으로) 불가능하기 때문에 람다 표현식을 자바에서는 어떻게 구현한 것일까 궁금해졌습니다. 예상했던 대로 자바는 역시나 인터페이스를 이용한 방식으로 해결하고 있었습니다. 이름하여 "함수형 인터페이스"라는 것인데 인터페이스에 "단 하나의 메서드 정의"만 담고 있는 것을 의미합니다.
이를 위해 자바 8에서는 함수형 인터페이스라는 "java.lang.FunctionalInterface" 어노테이션이 추가되었는데,
@java.lang.FunctionalInterface
public abstract interface java.lang.Runnable {
// Method descriptor #1 ()V
public abstract void run();
}
그다지 의미는 없다고 합니다. FunctionalInterface 어노테이션의 유무에 상관없이 메서드 하나만 정의되어 있다면 함수형 인터페이스로 인정받아 컴파일이 됩니다.
예를 들어, 덧셈을 수행하는 람다 표현식을 사용하고 싶은 경우 자바는 다음과 같이 구현할 수 있습니다.
package testapp;
public class Lambda {
interface IAdd
{
public int Add(int a, int b);
}
public static void main(String[] args)
{
IAdd func = (a, b) -> a + b;
System.out.println(func.Add(10, 20));
}
}
그리고 C#은 이렇게 구현합니다.
using System;
class Program
{
delegate int AddFunc(int a, int b);
static void Main(string[] args)
{
AddFunc func = (a, b) => a + b;
Console.WriteLine(func(10, 20));
}
}
보시는 바와 같이 자바의 경우 인터페이스(IAdd) 타입의 func 인스턴스에서 다시 Add 메서드를 호출하고 있습니다. 아마도 람다 표현식이란 결국 내부적으로 해당 인터페이스를 상속받은 임시 객체를 생성한 후 그것을 반환하는 것이 아닌가 예상해 봅니다. (조금 뒤에 이에 대해 더 살펴보겠습니다.)
어쨌든, 인터페이스를 경유하는 자바의 구현이 C#과 비교해 깔끔하지 못한 것은 delegate 타입의 부재에서 발생합니다.
그렇다면 실제로 어떻게 구현하고 있는지 바이트 코드 수준에서 한번 살펴볼까요? ^^
IAdd 인터페이스로 구현한 .class 파일을 javap로 역어셈블하면 다음과 같은 코드가 나옵니다.
E:\>E:\java\jdk1.8.0_05\bin\javap -p -c -v Lambda.class
Classfile /E:/java/workspace/testapp/bin/testapp/Lambda.class
...[생략]...
BootstrapMethods:
0: #54 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;
Method arguments:
#55 (II)I
#58 invokestatic testapp/Lambda.lambda$0:(II)I
#59 (II)I
InnerClasses:
public static final #65= #61 of #63; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
static #66= #27 of #1; //IAdd=class testapp/Lambda$IAdd of class testapp/Lambda
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Class #2 // testapp/Lambda
...[생략]...
#16 = NameAndType #17:#18 // Add:()Ltestapp/Lambda$IAdd;
#17 = Utf8 Add
#18 = Utf8 ()Ltestapp/Lambda$IAdd;
#19 = InvokeDynamic #0:#16 // #0:Add:()Ltestapp/Lambda$IAdd;
...[생략]...
#54 = MethodHandle #6:#48 // 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;
#55 = MethodType #30 // (II)I
#56 = Methodref #1.#57 // testapp/Lambda.lambda$0:(II)I
#57 = NameAndType #41:#30 // lambda$0:(II)I
#58 = MethodHandle #6:#56 // invokestatic testapp/Lambda.lambda$0:(II)I
#59 = MethodType #30 // (II)I
...[생략]...
{
...[생략]...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=2, args_size=1
0: invokedynamic #19, 0 // InvokeDynamic #0:Add:()Ltestapp/Lambda$IAdd;
5: astore_1
6: getstatic #20 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: bipush 10
12: bipush 20
14: invokeinterface #26, 3 // InterfaceMethod testapp/Lambda$IAdd.Add:(II)I
19: invokevirtual #31 // Method java/io/PrintStream.println:(I)V
22: return
LineNumberTable:
...[생략]...
private static int lambda$0(int, int);
descriptor: (II)I
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
LineNumberTable:
...[생략]...
}
큰일이군요. ^^ 자바 쪽 역어셈블 경험이 없어서 분석에 자신이 없습니다. 일단, 직관적으로 가보겠습니다. "IAdd func = (a, b) -> a + b;" 코드에 해당하는 라인을 시작으로,
invokedynamic #19
자바 7부터 추가된 invokedynamic 바이트 코드를 이용해 "#19 = InvokeDynamic
#0:#16" 항목으로 연결됩니다. 여기서 #0은 부트스트랩 메서드로 등록된 것중 번호가 0번인 항목을 가리키고,
BootstrapMethods:
0: #54 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;
Method arguments:
#55 (II)I
#58 invokestatic testapp/Lambda.lambda$0:(II)I
#59 (II)I
이와 함께 #16에 해당하는 "Add:()Ltestapp/Lambda$IAdd" 정보가 전달되는 것으로 봐서 아마도(순전히 추측입니다. 자바를 잘 모르므로!) LambdaMetafactory 타입의 metafactory 메서드에 의해 IAdd 인터페이스가 인스턴스화 되면서 반환되는 것으로 보입니다.
그렇게 반환된 인스턴스는 이후 astore_1 명령으로 첫 번째 로컬변수에 담기고 invokeinterface 명령어의 인자로 전달되기 위해 다시 aload_1 명령어로 스택에 적재되는 것을 볼 수 있습니다.
5: astore_1
6: getstatic #20 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: bipush 10
12: bipush 20
14: invokeinterface #26, 3 // InterfaceMethod testapp/Lambda$IAdd.Add:(II)I
19: invokevirtual #31 // Method java/io/PrintStream.println:(I)V
여기서 한 가지 궁금한 것이 있다면, 왜 자바 언어 개발자들이 성능상 다소 불리한 동적 생성을 통한 방식을 택했냐는 것입니다. 뭐랄까... 이것은 마치 C#에서 다음과 같이 구현하면 되는 것을,
AddDelegate func = (a, b) => a + b;
이보다 더 느린 동적 바인딩을 사용한 것이나 유사합니다.
dynamic func = (a, b) => a + b; // 이 코드는 컴파일되지 않습니다!
엄밀히 따지고 보면, 자바 컴파일러 역시 컴파일 시에 해당 인터페이스를 구현하는 concrete 클래스를 정의할 수 있는 능력이 충분하므로 성능에 더 유리한 정적 바인딩이 가능했을 것입니다. 암튼, 이 부분은 그 의도가 매우 궁금합니다. ^^
전반적으로 보면 자바의 람다는 delegate의 부재에도 불구하고 깨끗하게 잘 구현된 것 같습니다. ^^ 자... 그럼 마지막으로 자바의 람다에서 가장 말이 많은 클로저(closure)의 변수 캡처(Captured Variable)를 알아볼까요?
이미 많은 분들이 아시겠지만 자바의 클로저는 캡처된 변수의 값을 바꾸는 것을 허용하지 않습니다. 즉, 다음과 같은 코드는 컴파일 시에 오류가 발생합니다.
int captured = 100;
IAdd func = (a, b) -> a + b + (captured ++); // 컴파일 에러: Local variable captured defined in an enclosing scope must be final or effectively final
물론 값을 변화시키지 않으면 컴파일이 잘 됩니다.
int captured = 100;
IAdd func = (a, b) -> a + b + captured; // 컴파일 성공
어쩌면 이것이 자바의 람다 구현 방식에서 발생하는 제약이 아닌가 하는 의문을 가질 수 있는데요. 내부를 들여다 보면 그 물음에 답이 나옵니다. 어디... 위의 코드를 역어셈블을 통해 살펴볼까요? ^^
우선, 자바 컴파일러는 캡처된 변수를 invokedynamic 호출에 전달합니다.
0: bipush 100
2: istore_1
3: iload_1
4: invokedynamic #19, 0 // InvokeDynamic #0:Add:(I)Ltestapp/Lambda$IAdd;
그럼, 부트스트랩 메서드인 LambdaMetafactory.metafactory에서는 내부적으로 스택을 통해 전달된 그 변수들을 전달받고는,
BootstrapMethods:
0: #56 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;
Method arguments:
#57 (II)I
#60 invokestatic testapp/Lambda.lambda$0:(III)I
#61 (II)I
실행 시 "invokestatic testapp/Lambda.lambda
$0:(III)I"을 호출하면서 역시 lambda
$0 메서드에 캡처된 변수값을 전달할 것입니다.
private static int lambda$0(int, int, int);
descriptor: (III)I
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: iload_0
4: iadd
5: ireturn
수수께끼는 여기서 풀립니다. 보는 바와 같이 마지막 세 번째 int 변수 값에 캡처된 변수의 값이 전달되기 때문입니다. 따라서, 코드 내부에서 해당 변수의 값을 바꾼다고 해서 원래의 캡처된 변수의 값에는 아무런 영향을 줄 수가 없습니다.
이에 대한 해명으로 함수형 언어들의 캡처된 변수가 immutable을 지향하므로 자바도 이에 대한 철학을 따랐다고 나오긴 하는데... 글쎄요. 위와 같이 값 형식이 넘어간 경우에는 원본 객체의 값을 변화시킬 수 없지만 참조 형식으로 넘어간 경우라면,
Vector v = new Vector();
IAdd func = (a, b) -> { v.add(5); return a + b; };
System.out.println(func.Add(10, 20));
System.out.println(v.size()); // 출력 결과: 1
당연히 객체의 내부 상태가 변합니다. 자바 자체가 전체적으로 mutable이면서 함수형 언어의 철학을 클로저의 구현에서만 고집한다는 것은 그다지 합당한 이유는 아닌 것 같습니다. 결국, 구조적으로 봤을 때 원본 변수의 상태를 바꾸기에는 우회해야 할 것이 너무 많아졌기 때문에 아마도 복잡도를 생각해서 접은 것이 아닌가 생각됩니다.
[
내용 업데이트: 2014-06-11]
자바의 람다 인스턴스를 가지고 리플렉션을 해보면 좀 더 확실하게 정체를 알 수 있습니다.
IAdd func = (a, b) -> a + b;
Class<?> clazz = func.getClass();
System.out.println(clazz.getName()); // == testapp.Lambda$$Lambda$1/1510467688
System.out.println(clazz.getSuperclass().getName()); // == java.lang.Object
for (Class<?> cl : clazz.getInterfaces())
{
System.out.println(cl.getName()); // testapp.Lambda$IAdd
}
/*
Add
wait
wait
wait
equals
toString
hashCode
getClass
notify
notifyAll
*/
for (Method mt : clazz.getMethods())
{
System.out.println(mt.getName());
}
System.out.println(clazz.isSynthetic()); // == true
마지막의 isSynthetic으로 검색해 보면 다음과 같은 질문/답변을 찾을 수 있습니다.
Java has the ability to create classes at runtime. These classes are known as Synthetic Classes or Dynamic Proxies.
; http://stackoverflow.com/questions/399546/synthetic-class-in-java
A class may be marked as synthetic if it is generated by the compiler, that is, it does not appear in the source code.
Dynamic Proxy Classes
; http://docs.oracle.com/javase/1.5.0/docs/guide/reflection/proxy.html
A dynamic proxy class is a class that implements a list of interfaces specified at runtime
그리고
이규원님이 한가지 더 의견을 주셨네요. ^^
Java의 람다가 C#의 그것을 절대 따라올 수 없는 이유는 ExpressionTree라고 생각합니다.
그렇군요. ^^ C#의 람다는 "코드 또는 데이터로써" 다뤄질 수 있지만, 자바의 람다는 "코드"로써만 다뤄진다는 차이점이 있습니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]