이 책이 출간된지도 5년이나 지났다. 나는 그동안 무엇을 했는가..
자바 8 버전이 릴리즈 된지도 어언 10년이 지났다. 그럼에도 8에서 등장한 문법들은 여전히 '모던 자바'라고 불리며, 꽤 많은 개발자들이 여전히 낯설어 하고 있다.
그리고 나 역시 그 중 하나라는 점.. 그래서 이번 기회에 기술 부채를 해결하고자, 람다에 대해 이것저것 알아보고 기록한다.
람다식의 문법에 대해서는 따로 기록하지 않았다. 크게 설명할 내용도 없고, 문법만 안다고 사용할 수 있는 기능도 아니라고 생각한다.
많은 개발자들이 람다를 어려워하는 이유는 함수형 기법 이라는 새로운 패러다임의 등장, 그리고 기존의 클래스 단위로 형성된 객체 지향 패러다임과의 불편한 결합 때문일 것이다.
그렇기에 함수형 인터페이스와 그 패러다임을 구현한 람다식의 특징과 주의점에 대해 주로 기록하였음을 미리 알린다.
- 함수형 인터페이스(Function interface)를 구현할 때 사용한다.
- 하나의 메소드이다. 메소드와 동일한 스택(Stack)을 갖는다.
- 일급 객체(First Class Object)로서 파라미터나 반환값 등으로 사용할 수 있다.
함수형 인터페이스는 추상 메소드가 하나만 존재는 인터페이스를 의미한다. 그리고 람다식은 그 추상 메소드를 구현하는 역할을 한다. 반대로 함수형 인터페이스가 아닌 다른 변수에는 람다식을 사용할 수 없다.
가령 아래와 같은 함수형 인터페이스가 존재한다고 치자.
@FunctionalInterface
public interface CustomFuncInterface<P, R> {
// 단 하나의 추상 메소드만 존재한다.
abstract R onlyOne(P param);
}
// CustomFuncInterface의 단 하나 존재하는 onlyOne 메소드를 구현한다.
CustomFuncInterface<String, String> lambdaFunc1 = (String param) -> {
return "람다로 구현한 결과 : " + param;
};
// 파라미터의 자료형을 입력하지 않아도 컴파일 과정에서 알아서 추론할 수 있다.
CustomFuncInterface<String, String> lambdaFunc2 = (param) -> {
return "람다로 구현한 결과 : " + param;
};
// 람다식을 사용하지 않고, 직접 인터페이스를 호출하는 방법도 있다.
CustomFuncInterface<String, String> impl = new CustomFuncInterface<String, String>() {
@Override
public String onlyOne(String param) {
return "구현체로 구현한 결과 : " + param;
}
};
// 추상 메소드를 구현했으니, 이제 사용할 수 있다.
String result1 = lambdaFunc1.onlyOne("반환값1"); // 람다로 구현한 결과 : 반환값1
String result2 = lambdaFunc2.onlyOne("반환값2"); // 람다로 구현한 결과 : 반환값2
String result3 = impl.onlyOne("반환값3"); // 구현체로 구현한 결과 : 반환값3
public interface NoneFunctionInterface<P, R> {
// 여러 개의 추상 메소드를 가지고 있으므로 함수형 인터페이스가 성립되지 않는다.
abstract R one(P param);
abstract R two(P param);
}
public class LongLambdaExpress() {
// 에러 발생!
// String은 추상 메소드가 존재하지 않기 때문에 람다식을 사용할 수 없다.
String nonLambda = (String param) -> {
return param;
};
/**
* 에러 발생!
* 람다식의 문법 구조상, 여러 개의 추상 메소드를 동시에 구현하거나,
* 어느 한 가지의 메소드만을 특정하여 구현할 수 없다.
* */
NoneFunctionInterface<String, String> noneLambda = (param) -> {
return "에러 발생" + param;
};
}
람다식은 메소드이다. 정확히는 익명 메소드라고 불린다. 그렇기 때문에 일반적인 자바 메소드와 동일한 스코프(유효범위)를 갖게 된다.
하지만 람다식은 일반적인 메소드와 달리, 사용하는 지점에서 직접 메소드의 내용을 작성해야 한다. 메소드 안에서 람다식을 사용하면, 메소드 안에 메소드가 생기는 기형적인 상황을 마주하게 된다.
그럴 때는 침착하게 '이건 메소드야.'라는 리마인드를 할 필요가 있다.
public String outerMethod() {
String param1 = "parameter";
String param2 = "parameter2";
// 에러 발생!
CustomFuncInterface<String, String> innerMethod1 = (param1/* 변수명 중복! */) -> {
String param2 = "same memory"; /* 변수명 중복! */
return "";
};
// 에러 발생!
CustomFuncInterface<String, String> innerMethod2 = (innerParam) -> {
// 외부에서 정의된 변수를 사용할 수 있다.
innerParam = innerParam + param1;
param1 = "ddd"; /* 하지만 외부에서 정의된 변수를 변경할 수 없다! */
return innerParam;
};
// 람다식 내부에서 처리된 return은 람다식에 대한 return이다.
// 람다식 내부에서 람다식 외부 메소드에 대한 return을 할 수 없다.
return "";
}
위 소스에서 람다식에 대한 3가지의 특징을 간략하게 볼 수 있다.
위와 같은 특징이 나오는 이유는, 람다식과 외부 메소드는 서로 다른 메모리를 참조하기 때문이다.
자바에서 쓰레드는 각각 스택(Stack) 메모리를 할당 받는다. 위 소스같은 경우는 메소드 단위로 쓰레드가 생성되고 있으므로, outerMethod()에 스택이 할당되었으며, outerMethod()에서 선언한 변수들이 해당 스택에 저장되었다.
중요한건, 람다식 역시 하나의 메소드로 분류된다는 것이다. 즉, 람다식이 감지되는 순간, 해당 람다식에도 각각 별도의 스택이 할당된다. innerMethod1과 innerMethod2에 각각 별도의 스택이 할당되었다.
여기서 innerMethod1과 innerMethod2는 outerMethod()의 스택에 저장된 변수 등을 그대로 복사하여 각각 자신의 스택에 저장한다. 자기 자신을 포함하는 메소드의 스택을 복사하여 자신의 스택에 저장한 것이다. 이를 통상적으로 람다 캡처링이라 부른다.
때문에 외부 메소드의 변수를 람다식 내에서 참조할 수 있다. 문제는 외부 메소드와 람다식은 여전히 다른 메모리를 가지고 있다는 점이다. 람다식이 외부 메소드의 변수값을 변경하게 되더라도 외부 메소드의 변수값에는 아무런 영향을 주지 못한다.
이러한 모순을 해결하기 위해, 람다식은 외부 스택에서 복사해온 변수 값은 변경하지 못하게 설계되었다.
private String globalString = "Heap 메모리에 저장된 전역 변수";
public String outerMethod() {
String outerMethodString1 = "outerMethodString1";
String outerMethodString2 = "outerMethodString2";
List<String> outerMethodList = new ArrayList<>();
CustomFuncInterface<String, String> innerMethod = (param) -> {
param = outerMethodString1;
param = outerMethodString2;
// stack이 아닌 heap 메모리에 저장된 변수는 조회, 변경이 모두 가능하다.
param = globalString;
this.globalString = param;
// 컬렉션 프레임워크의 경우, 변수 값이 주소값이므로 주소 내부의 내용을 변경할 수 있다.
outerMethodList.add("람다 안에서 넣은 값");
return param;
};
innerMethod.onlyOne(outerMethodString1);
System.out.println("outerMethodList = " + outerMethodList); /* 람다 안에서 넣은 값 */
return "";
}
일급 객체(영어: first-class object)란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리킨다. - 위키 백과 발췌 -
자바에서 일급 객체는 변수의 할당, 파라미터, 반환 값으로 사용할 수 있어야 한다.
변수의 할당이야 함수형 인터페이스의 자료형을 갖는 변수에 할당하는 예제는 위에서 여러번 보았다. 그렇다면 파라미터나, 반환 값으로 사용이 가능할까?
public class LambdaEx {
// 람다식을 반환 하는 메소드
public CustomFuncInterface<String, String> lambdaEx1() {
return (param) -> {
System.out.println("param = " + param);
return param;
};
}
// 람다식을 파라미터로 받는 메소드
public CustomFuncInterface<String, String> lambdaEx2(
CustomFuncInterface<String, String> lambdaParam ) {
return lambdaParam;
}
}
메소드를 다음과 같이 작성 했을 때, 컴파일 오류가 발생하지는 않았다.
그렇다면 실제로 람다식을 반환하는 메소드와, 람다식을 파라미터로 받는 메소드를 호출해보자
public static void main(String[] args) {
LambdaEx lambdaEx = new LambdaEx();
// 람다식을 반환하는 메소드 호출
CustomFuncInterface<String, String> result1 = lambdaEx.lambdaEx1();
// 람다식을 파라미터로 받는 메소드 호출
CustomFuncInterface<String, String> result2 = lambdaEx.lambdaEx2( param -> {
System.out.println("param = " + param);
return param;
} );
result1.onlyOne("파라미터1");
result2.onlyOne("파라미터2");
}
> Task :Main.main()
param = 파라미터1
param = 파라미터2
문제없이 작동한다. 그런데 실제로 저런 형태의 소스가 존재할까 싶지만, 컬렉션 프레임워크의 Stream이나, 자바 8 이후부터 공개 된 라이브러리 등에서 의외로 쉽게 찾아볼 수 있다.
// 실제 Stream의 메소드 중, map()의 형태
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
List<String> collection = new ArrayList<String>();
collection.add("첫번쨰 값");
collection.add("두번쨰 값");
// Stream 메소드 중, map()을 사용. Function이라는 함수형 인터페이스를 파라미터로 받는다.
collection = collection.stream()
.map( val -> { return "stream을 거친 " + val; })
.collect(Collectors.toList());
// forEach 메소드 역시, Predicate라는 함수형 인터페이스를 파라미터로 받는다.
collection.forEach( val ->{
System.out.println("val = " + val);
});
> Task :Main.main()
val = stream을 거친 첫번쨰 값
val = stream을 거친 두번쨰 값
람다는 절대적이거나 독보적인 문법이 아니다. 람다식의 용도는 오직 함수형 인터페이스를 구현하기 위함일 뿐이고, 람다식 외에도 함수형 인터페이스를 구현하는 방법은 얼마든지 있다.
모든 사람들이 입을 모아 말하기로는 람다식을 사용함으로서 소스가 간결해지고 직관적으로 변했다고 한다.
인터페이스를 사용 지점마다 일일이 구현해서 쓰는 사람은 없을 것이다. 불가능한 것은 아니지만 소스가 심각하게 더러워질 뿐더러, 변화무쌍한 메소드가 아닌 이상, 보통은 구현체로 따로 구현해서 사용한다.
하지만 정말로 변화무쌍한 메소드를 사용해야 하는 경우에는 어떻게 해야 할까. 함수형 인터페이스는 일반적인 인터페이스와 달리 변화무쌍한 메소드를 사용하기 위한 목적이 크다.
// Stream 메소드 중, map()을 사용. Function이라는 함수형 인터페이스를 파라미터로 받는다.
// 람다식을 사용한 예
collection = collection.stream()
.map( val -> { return "stream을 거친 " + val; })
.collect(Collectors.toList());
// Stream 메소드 중, map()을 사용. Function이라는 함수형 인터페이스를 파라미터로 받는다.
// 람다식을 사용하지 않은 예
collection = collection.stream()
.map( new Function<String, String>() {
@Override
public String apply(String val) {
return "stream을 거친 " + val;
}
} )
.collect(Collectors.toList());
여러번 강조하지만 자바에서 람다식은 함수형 인터페이스를 구현하기 위해 만들어졌다. 함수형 인터페이스를 구현하는 소스가 람다식만 있는 것은 아니지만, 람다식이야 말로 함수형 인터페이스를 가장 간단하고 직관적으로 구현할 수 있는 방법인건 맞는 것 같다.