함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고, 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다. - 위키백과
람다를 이해하기 위해선 먼저, 함수형 프로그래밍에 대한 개념의 이해가 필요하다.
함수형 프로그래밍은 절차지향과 객체지향처럼 하나의 방법론 중 하나로, 다른 말로는 선언형 프로그래밍이라 한다. 이와 대조적으로 객체지향 프로그래밍은 명령형 프로그래밍이다.
명령형? 선언형?
- 명령형: 클래스에서 메서드를 정의하고, 필요할 때 메서드를 호출
- 선언형: 데이터가 입력으로 주어지고, 데이터가 처리되는 과정을 정의하는 것으로 동작.
함수형 인터페이스란, 추상 메서드가 오직 하나인 인터페이스를 말한다. default method 또는 static method 는 여러개 존재해도 상관없다.
인터페이스 앞에 @FunctionalInterface
애노테이션을 사용하는데, 이는 해당 인터페이스가 함수형 인터페이스 조건에 부합한지 컴파일 단계에서 검사하는 기능을 가지고 있다. 애노테이션을 굳이 붙이지 않아도 동작하고 사용하는데 문제없지만, 검증과 유지보수를 위해 작성해주는 것이 좋다.
이름 | 기호 | 번역 |
---|---|---|
Predicate | T -> boolean | 단언하다 |
Consumer | T -> void | 소비자 |
Supplier | () -> T | 제공자 |
Function | T -> R | 함수 |
Comparator | (T, T) -> int | 비교기 |
Runnable | () -> void | 실행 가능한 |
Callable | () -> T | 호출 가능한 |
대표적인 몇가지만 뽑아보았으며, 더 많은 목록은 Oracle Doc에서 확인할 수 있다.
public class LambdaTest {
@FunctionalInterface
interface MyLambdaFunction {
String method();
}
@Test
void functionalInterfaceTest() {
System.out.println(new MyLambdaFunction() {
@Override
public String method() {
return "Hello World";
}
}.method());
}
}
람다 표현식이란, 함수형 인터페이스를 좀 더 간편하고 간결하게 선언할 수 있도록 지원하는 문법이다.
기본적으로 () -> { return ; }
의 형태로 선언할 수 있으며, 인자의 갯수나 구문의 줄에 따라 조금씩 변경될 수 있다.
규칙은 다음과 같다.
public class LambdaTest {
@Test
void lambdaRole() {
// 인자가 0개, 소괄호 생략 불가능
Runnable run = () -> {};
// 인자가 1개, 소괄호 생략 가능
Consumer<Integer> cons = a -> {};
// 인자가 2개이상, 소괄호 생략 불가능
BiConsumer<Integer, Integer> biCons = (a, b) -> {};
// 구문이 한 줄인 경우, 중괄호 + return 생략 가능.
Comparator<Integer> comp = (a, b) -> a > b ? a : b;
// 구문이 여러 줄인 경우, 중괄호 + return 생략 불가능.
Comparator<Integer> comp2 = (a, b) -> {
if( a > b ) {
return a;
} else {
return b;
}
};
}
}
메서드 참조란, 함수형 인터페이스를 람다식이 아닌 일반 메서드를 참조시켜 선언하는 방법이다. 일반 메서드를 참조하기 위해서는 다음의 3가지 조건을 만족해야 한다.
참조 가능한 메서드는 일반 메서드, Static 메서드, 생성자가 있으며, 클래스이름::메서드이름 으로 참조할 수 있다. 이렇게 참조를 하면 함수형 인터페이스로 반환된다.
public class LambdaTest {
@Test
void MethodReferenceTest() {
// 일반 메서드 참조
Function<String, Integer> function = String::length;
function.apply("Hello World");
// 일반 메서드 참조 2
Consumer<String> consumer = System.out::println;
consumer.accept("Hello World");
// Static 메서드 참조
Predicate<Object> predicate = Objects::isNull;
predicate.test(consumer);
// 생성자 참조 - 여기에서는 기본 생성자를 참조
Supplier<String> supplier = String::new;
String a = supplier.get();
predicate.test(a);
}
}
public class LambdaTest {
@Test
void thisTest() {
Runnable funcCons = new Runnable() {
@Override
public void run() {
System.out.println("funcCons.run.this = " + this);
System.out.println("LambdaTest.this = " + LambdaTest.this);
}
};
Runnable lamCons = () -> {
System.out.println("lamCons.this = " + this);
System.out.println("LambdaTest.this = " + LambdaTest.this);
};
System.out.println("funcCons = " + funcCons);
funcCons.run();
System.out.println("--------------------");
System.out.println("lamCons = " + lamCons);
lamCons.run();
}
}
funcCons = myTest.LambdaTest$3@376b4233
funcCons.run.this = myTest.LambdaTest$3@376b4233
LambdaTest.this = myTest.LambdaTest@2fd66ad3
--------------------
lamCons = myTest.LambdaTest$$Lambda$334/0x0000000800ca25f0@5d11346a
lamCons.this = myTest.LambdaTest@2fd66ad3
LambdaTest.this = myTest.LambdaTest@2fd66ad3
위의 코드에서 함수형 인터페이스와 람다식으로 생성된 2개의 익명 함수가 있다. this를 이용해 이 들의 주소값을 출력해보자 신기한 결과가 출력된다.
funcCons의 주소값과 내부의 run 메서드에서 this를 이용해 출력된 주소값이 서로 같은 것을 볼 수 있다.
어찌보면 당연한 일이다. new 를 이용해 새로운 객체를 할당해 Heap 영역에 할당했고, 이 객체는 LambdaTest 클래스와 다른 Heap 영역에 할당되어 있는 별개의 객체이기 때문이다.
그래서 여기에서 외부 변수를 사용하기 위해서는 this가 아닌, LambdaTest.this 를 이용해야 한다.
출력된 주소값을 보면 알겠지만, 람다식을 넣은 변수 lamCons의 주소값이 이상하게 되어있고, 람다식 내부의 this와 LambdaTest.this 가 같은 것을 볼 수 있다. 이게 어찌된 일일까?
결론부터 말하자면, 람다식은 컴파일이 될 때 객체를 생성하는 것이 아닌, LambdaTest 클래스의 private static 메서드로 생성된다.
이 때문에 함수형 인터페이스와 달리 this가 LambdaTest 클래스를 가리키는 것이다.
이에 대한 더 자세한 내용은 이 블로그를 참조하자.
람다 내부에서는 인스턴스 변수, 지역 변수, 정적 변수를 자유롭게 캡처할 수 있지만, 이 변수는 final이거나 final처러 사용되어야 한다. ( 할당만 되고, 변경은 한 번도 하지 않은 변수에 한해 사용 가능하다. )
함수형 프로그래밍의 특성 중 가장 중요한 것이 함수 안에서 일어난 일 때문에, 외부에서 변화가 일어나서는 안된다는 것인데, 이 때문에 위와 같은 제약을 걸어뒀다고 생각하면 된다.
그런데 이런 제약이 지역 변수는 문제가 없는데, 인스턴스 변수는 제약에 상관없이 수정이 가능하다. 이게 어떻게 된 일일까?
public class LambdaTest {
int outerField = 10;
@Test
void functionFieldTest() {
int innerField = 20;
Consumer<Integer> consumer = new Consumer<>() {
@Override
public void accept(Integer arg) {
int lambdaField = 30;
System.out.println("outerField = " + outerField); // 10
outerField = 1000; // 변경 가능
System.out.println("outerField = " + outerField); // 1000
System.out.println("innerField = " + innerField); // 20
// innerField = 2000; // 컴파일 에러
// System.out.println("innerField = " + innerField); // 실행되지 않음
System.out.println("lambdaField = " + lambdaField); // 30
lambdaField = 3000; // 변경 가능
System.out.println("lambdaField = " + lambdaField); // 3000
System.out.println("arg = " + arg); // 40
arg = 4000; // 변경 가능
System.out.println("arg = " + arg); // 4000
}
};
int arg = 40;
consumer.accept(arg);
System.out.println("--------");
System.out.println("outerField = " + outerField); // 1000
System.out.println("innerField = " + innerField); // 20
System.out.println("arg = " + arg); // 40
}
}
outerField = 10
outerField = 1000
innerField = 20
lambdaField = 30
lambdaField = 3000
arg = 40
arg = 4000
--------
outerField = 1000
innerField = 20
arg = 40
public class LambdaTest {
int outerField = 10;
@Test
void lambdaFieldTest() {
int innerField = 20;
Consumer<Integer> consumer = (arg) -> {
int lambdaField = 30;
System.out.println("outerField = " + outerField); // 10
outerField = 1000; // 변경 가능
System.out.println("outerField = " + outerField); // 1000
System.out.println("innerField = " + innerField); // 20
// innerField = 2000; // 컴파일 에러
// System.out.println("innerField = " + innerField); // 실행되지 않음
System.out.println("lambdaField = " + lambdaField); // 30
lambdaField = 3000; // 변경 가능
System.out.println("lambdaField = " + lambdaField); // 3000
System.out.println("arg = " + arg); // 40
arg = 4000; // 변경 가능
System.out.println("arg = " + arg); // 4000
};
int arg = 40;
consumer.accept(arg);
System.out.println("--------");
System.out.println("outerField = " + outerField); // 1000
System.out.println("innerField = " + innerField); // 20
System.out.println("arg = " + arg); // 40
}
}
outerField = 10
outerField = 1000
innerField = 20
lambdaField = 30
lambdaField = 3000
arg = 40
arg = 4000
--------
outerField = 1000
innerField = 20
arg = 40
결과적으로는 인스턴스 변수와 지역 변수의 저장 구역이 다르기 때문에 이 현상이 발생한다.
이를 자세히 설명한 글을 인터넷에서 찾아서 공유하고자 한다. 이 블로그에서 더 자세히 설명되어 있기에 들어가서 보기 바란다. 아래의 내용은 블로그의 글을 정리한 내용이다.
해당 내용을 다루는 몇몇 글에서 “람다식에서 참조하는 외부 변수는 final 혹은 effectively final 이어야한다.” 라고 설명하고 있다. 하지만, 이는 엄밀히 말하면 틀린 내용이다.
엄밀히 말하면 외부 변수가 아닌, 외부 지역 변수만 위의 제약이 걸려있다. 좀 더 자세히 말하자면, 지역 변수를 제외한 인스턴스 변수, 정적 변수는 이에 해당되지 않는다는 이야기이다.
람다식에서는 외부 지역 변수를 그대로 사용하지 못하고 복사본을 사용해야 하는데 그 이유는 다음과 같다.
만약 참조하고자 하는 지역 변수가 final 혹은 effectively final이 아닐 경우, 즉 변경이 가능할 경우 다음과 같은 문제가 발생할 수 있다.
public void executelocalVariableInMultiThread() {
boolean shouldRun = true;
executor.execute(() -> {
while (shouldRun) {
// do operation
}
});
shouldRun = false;
}
람다식이 어떤 쓰레드에서 수행될지 미리 알 수 없다. 이 얘기는 곧, 외부 지역 변수를 다루는 쓰레드와 람다식이 수행되는 쓰레드가 다를 수 있다는 의미이다.
지역 변수 값을 제어하는 쓰레드 A, 람다식을 수행하는 쓰레드 B가 있다고 가정하자. 문제는 다음과 같다.
쓰레드 B의 shouldRun값이 가장 최신 값으로 복사되어 전달됐는지 확신할 수 없다! 왜냐하면 지역 변수를 쓰레드간에 sync해주는 것은 불가능하기 때문이다! 값이 보장되지 않는다면 매번 람다식의 결과가 달라질 수 있게 된다. 예측할 수 없는 코드의 의미가 과연 있을까?
이러한 이유로 인해 외부 지역 변수는 전달되는 복사본이 변경되지 않은 최신 값임을 보장하기 위해 final 혹은 effectively final 이어야하는 제약이 필요하다.
그럼, 이미 복사가 된 값을 익명 함수 내부에서 변경하면 문제가 없는 것 아닌가?
결론은 아니다. 컴파일 된 람다식은 static 메소드 형태로 변경이 되는데, 이때 복사된 값이 파라미터로 전달되므로 마찬가지로 스택 영역에 존재하기 때문에 sync를 해주는 것도 불가능하다.
private int instanceNumber = 1;
private static int staticNumber = 1;
public void testPlusByInstanceVariable() {
instanceNumber = 2;
Addable addableImple = () -> instanceNumber + 1;
}
public void testPlusByStaticVariable() {
staticNumber = 2;
Addable addableImple = () -> staticNumber + 1;
}
인스턴스 변수나 클래스 변수를 저장하고 있는 메모리 영역은 공통 영역(Heap)이고 값이 메모리에서 바로 회수되지 않는다. 따라서 복사 과정이 불필요하고 참조 시 최신값임을 보장할 수 있다.
다만 멀티 쓰레드 환경일 경우 volatile, synchronized 등을 이용하여 sync를 맞춰주는 작업을 잊어서는 안된다.