...
람다 표현식 (Lambda Expression)
람다 표현식(lambda expression)이란 함수형 프로그래밍을 구성하기 위한 함수식이며, 간단히 말해 자바의 메소드를 간결한 함수 식으로 표현한 것이다.
지금까지 자바에서는 메서드를 하나 표현하려면 클래스를 정의해야 했다. 하지만 람다식으로 표현하면 메서드의 이름과 반환값을 생략할 수 있고 이를 변수에 넣어 자바 코드가 매우 간결해지는 장점이 있다.
아래 그림에서 보듯이 int add(int a, int b) {} 메소드 표현식을, 메서드 타입, 메서드 이름, 매개변수 타입, 중괄호, return 문을 생략하고, 화살표 기호를 넣음으로써 코드를 혁명적으로 함축했음을 볼 수 있다. 이러한 특징으로 람다식을 이름이 없는 함수 익명 함수(anonymous function) 라고도 한다.
int add(int x, int y) {
return x + y;
}
// 위의 메서드를 람다 표현식을 이용해 아래와 같이 단축 시킬수 있다. (메서드 반환 타입, 메서드 이름 생략)
(int x, int y) -> {
return x + y;
};
// 매개변수 타입도 생략 할 수 있다.
(x, y) -> {
return x + y;
};
// 함수에 리턴문 한줄만 있을 경우 더욱 더 단축 시킬 수 있다. (중괄호, return 생략)
(x, y) -> x + y;
타입을 생략을 해도 컴파일러가 에러를 띄우지않는 이유는, 컴파일러 나름대로 생략된 타입 위치를 추론하여 동작하게 해주기 때문이다.
람다식이 처음에는 생소하게 느껴져 오히려 거부감이 들 수 있다. 하지만 문법이 혁신적으로 간결해 지는 것만큼, 람다식에 익숙해지면 가독성 면에서 큰 장점을 얻게 된다. 특히 컬렉션(Collection)의 요소를 필터링하거나 매핑하여 원하는 결과를 쉽게 얻을 수 있다.
람다식과 자바스크립트 익명 화살표 함수
만일 자바를 배우기 앞서 자바스크립트 프로그래밍을 먼저 배운 독자 분들이라면 자바의 람다 표현식을 이해하는데 있어 매우 수월할 것이다. 사실 자바스크립트의 익명 화살표 함수 자체가 람다 함수 일종이기 때문이다. 괄호를 생략하거나 등 람다의 기본 문법 체계도 자바와 자바스크리브 둘다 비슷하다. 단지 화살표 모양이 => 와 -> 로 다를 뿐이다.
람다식을 자바스크립트 문법 형태와 자바의 문법 형태를 비교하자면 아래 코드와 같다.
const MyFunction = {
print: function() {}
};
MyFunction.print = (str) => console.log(str);
let myfunc = MyFunction;
myfunc.print("Hello World");
interface MyFunction {
void print(String str);
}
public class Main {
public static void main(String[] args) {
MyFunction myfunc = (str) -> System.out.println(str);
myfunc.print("Hello World");
}
}
변수에 함수를 담을때, 자바스크립트는 약타입 언어라 타입에 관계없이 자유롭게 받을 수 있지만, 자바 같은 경우 강타입 언어 이기 때문에 반드시 함수에 대한 타입을 선언하여야 한다. 하지만 자바에는 8가지 타입(primitive 타입과 reference 타입) 밖에 없기 때문에 함수 데이터 자체를 담을수 있을만한 자료형이 딱히 적합한 것이 없다. 그래서 자바 개발진들은 인터페이스를 익명 구현 객체 타입으로써, 함수를 해당 인터페이스 타입으로 받을 수 있게 설계한 것이다.
람다식과 함수형 인터페이스
람다식의 형태를 보면 마치 자바의 메소드를 변수로 선언하는 것 처럼 보이지만, 사실 자바는 메소드를 단독으로 선언할 수는 없다. 형태만 그렇게 보일 뿐이지 코드를 보면 람다 함수식을 변수에 대입하고 변수에서 메서드를 호출해서 사용하는 것이 마치 객체와 다름이 없다.
MyFunction myfunc = (str) -> System.out.println(str);
myfunc.print("Hello World");
사실 람다식도 결국은 객체이다. 정확히 말하면 인터페이스를 익명 클래스로 구현한 익명 구현 객체를 짧게 표현한 것 뿐이다.
객체 지향 방식 vs 람다 표현 방식
이에 대해서 기존 자바7에서 표현 했던 객체 지향 방식과 람다 표현 방식을 비교해보며 이해해보자.
다음 IAdd 라는 인터페이스가 있고 add() 추상 메서드가 있다. 우리는 이 인터페이스를 구현하여 메서드를 정의해 덧셈 기능을 이용할 예정이다.
기존에는 인터페이스를 클래스에 implements 하고 오버라이딩하여 사용해 왔다.
interface IAdd {
int add(int x, int y);
}
class Add implements IAdd {
public int add(int x, int y) {
return x + y;
}
}
public class Main {
public static void main(String[] args) {
// 생 클래스로 메소드 사용하기
Add a = new Add();
int result1 = a.add(1, 2);
System.out.println(result1);
}
}
더 나아가선 한번만 사용하고 버려질 클래스라면, 굳이 번거롭게 클래스를 선언하지 말고 익명 클래스로 일회용 오버라이딩 하여 사용하기도 하였다.
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
// 익명 클래스로 정의해 사용하기 (일회용)
Iadd a = new IAdd() {
public int add(int x, int y) {
return x + y;
}
};
int result2 = a.add(1, 2);
System.out.println(result2);
}
}
그리고 람다는 이 익명클래스 코드 부분을 짧게 표현한 것이다.
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
// 람다 표현식으로 함축 하기
IAdd lambda = (x, y) -> { return x + y; }; // 람다식 끝에 세미콜론을 잊지말자
int result3 = lambda.add(1, 2);
System.out.println(result3);
}
}
람다식 객체를 콘솔에 출력 해보면, 익명 클래스 표현 형식과 또다른 외부클래스명$$Lambda$번호 와 같은 독자적인 표현 형식을 지니고 있음을 알 수 있다.
즉, 아무 클래스나 추상 클래스의 메소드를 람다식으로 줄이거나 하는 행위는 못한다라는 뜻이다. 오로지 인터페이스로 선언한 익명 구현 객체만이 람다식으로 표현이 가능하다. 그리고 람다 표현이 가능한 이러한 인터페이스를 가리켜 함수형 인터페이스라 총칭한다.
함수형 인터페이스 란?
함수형 인터페이스란 딱 하나의 추상 메소드가 선언된 인터페이스를 말한다. 위의 IAdd 인터페이스 예제 코드가 바로 함수형 인터페이스 이다. 그리고 람다식은 함수형 인터페이스 안에 정의된 하나의 추상 메소드 선언을 짧게 표현한 것이다.
생각해보면 람다식 자체가 하나의 메소드를 한줄로 정의하는 표현식이기 때문에, 인터페이스에 두개 이상 추상 메서드가 들어있으면 이를 코드로 겹쳐 표현할 방법이 달리 없기 때문에, 오로지 추상 메소드 한개만 가진 인터페이스가 람다식의 타겟 타입(targe type)이 될 수 있는 것이다.
단, Java 8 버전 부터 이용이 가능한 인터페이스의 final 상수나 default, static, private 메서드는 추상 메서드가 아니기 때문에, 이들 여러개가 인터페이스에 들어있어도 오로지 추상 메서드가 한개이면 함수형 인터페이스로 취급 된다.
// 함수형 인터페이스가 될 수 있다.
interface IAdd {
int add(int x, int y);
}
// 함수형 인터페이스가 될수 없다.
interface ICalculate {
int add(int x, int y);
int min(int x, int y);
}
// 구성요소가 많아도 결국 추상 메서드는 한개이기 때문에 함수형 인터페이스이다.
interface IAdd {
int add(int x, int y);
final boolean isNumber = true; // final 상수
default void print() {}; // 디폴트 메서드
static void print2() {}; // static 메서드
}
@FunctionalInterface
나만의 함수적 인터페이스를 만들 때 두 개 이상의 추상 메소드가 선언되지 않도록 컴파일러가 checking 해주는 기능이 있는데, 인터페이스 선언 시 @FunctionalInterface 어노테이션을 붙여주게 된다면 두 개 이상의 메소드 선언 시 컴파일 오류를 발생시켜준다. 이는 개발자의 실수를 줄여주는 역할을 한다.
@FunctionalInterface
public interface MyFunctional {
public void method();
public void otherMethod(); // 컴파일 오류 발생
}
이밖의 함수형 인터페이스의 개념 원리 및 자바에서 제공하는 함수형 인터페이스 표준 API 종류에 대해서 상세히 이해하고 싶다면 다음 포스팅을 참고하길 바란다.
람다식의 타입 추론
람다식으로 코드를 혁신적으로 줄일 수 있다는 점은 알았다. 그런데 리턴 타입도 파라미터 타입도 없는 람다식을 컴파일러가 이 함수가 어떤 타입 함수인지 알고 문법을 허용하는 것일까?
사실 컴파일러 스스로 람다 함수식을 보고 추론하여 타입을 유추하기 때문에 가능한 것이다. 무슨 AI 처럼 추론할 정도는 아니고 사람이 미리 정의해놓은 정의문을 보고 추론해주는 것이다.
위의 예제는 아주 간단한 예제이고, 대부분의 함수형 인터페이스를 이용하게 되면 제네릭(Generics)을 사용하게 되는데, 컴파일러가 타입을 추론하는 데 필요한 타입 정보 대부분을 제네릭에서 판별하여 얻는다고 보면 된다.
다음 코드를 봐보자. List 자료형을 만들고 리스트의 타입 파라미터를 String으로 지정하였다. 그리고 Collections 클래스의 sort 메소드를 불러와 첫번째 매개변수로는 리스트 객체를 두번째 매개변수로는 람다 함수를 전달 하였다.
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> words = Arrays.asList("aaa", "bbb", "ccc", "ddd");
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
}
}
참고로 아래의 Collections 클래스의 sort 메서드 정의문을 보면, 람다함수의 함수형 인터페이스 타입은 java.util.Comparator 로 지정되어 있으며, 람다의 매개변수 타입은 Comparator 인터페이스의 제네릭 T 타입으로 지정되어 있는걸 볼 수 있다.
이러한 형태에서 컴파일러가 타입을 유추하는 순서는 다음과 같이 된다.
- sort 메소드의 첫번째 매개변수로 List<String> 형태의 객체가 들어온다.
- 첫번째 매개변수의 타입 지정에 의해 sort 메소드의 제네릭 타입 매개변수는 모두 String으로 지정되게 된다.
- 따라서 Comparator 인터페이스 제네릭 타입도 String으로 지정되며, 추상 메서드의 매개변수 타입도 String으로 지정된다.
- 최종적으로 람다 함수의 타입 구성은 int형 메소드 반환 타입과 String 형 매개변수 타입 두개로 추론되게 된다.
단, 개발자 입장에선 코드를 복기할때 람다식의 타입을 위와 같이 유추하기에는 시간이 걸리기에, 상황에 따라 명시적으로 람다식 파라미터에 타입을 기재하기도 한다. 무엇이 좋은지 정답은 없고 상황에 따라 개발자가 결정해야 한다.
Collections.sort(words, (String s1, String s2) -> Integer.compare(s1.length(), s2.length()));
람다 표현식 활용하기
다양한 람다식의 할당
람다식의 가장 큰 특징은 바로 변수에 정수를 할당하듯이 함수를 할당할 수 있다는 것이다.
사실 함수도 일반 데이터 처럼 메모리 주소가 할당 되어 있다. 지금까지 클래스로 메소드를 사용해왔기에 메소드의 메모리 주소를 변수에 할당하는 일은 없었지만, 람다식을 이용하면 함수(실행코드)의 주소를 사용하여 C언어, 파이썬 같은 함수 스타일의 프로그래밍을 작성할 수 있게 된 것이다.
람다식 변수 할당
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
IAdd lambda = (x, y) -> x + y; // 함수를 변수에 할당
lambda.add(1, 2); // 함수 사용
}
}
람다식 매개변수 할당
특히 람다식은 메소드의 매개변수에 바로 입력값으로 넣는 방식으로 정말 자주 애용된다. 이것을 함수를 메소드의 매개변수로 넘겨준다고 표현한다.
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
int n = result( (x, y) -> x + y ); // 메소드의 매개변수에 람다식을 전달
System.out.println(n); // 3
}
public static int result(IAdd lambda) {
return lambda.add(1,2);
}
}
기존의 자바의 메소드 같은 경우 변수에 할당하거나 매개변수로 넣거나 리턴값으로 사용하는 행위는 꿈도 못 꾸어 왔을 것이다. 하지만 람다식이 이런식으로 응용이 가능한 이유는 람다는 익명 함수(Anonymous Function)이며, 익명 함수는 모두 일급 객체로 취급 되기 때문이다.
함수를 매개변수로 전달해서 사용하는 것을 무슨 신박한 기능처럼 소개했지만, 사실 클래스 참조 객체를 넘겨 준 것과 다름이 없다. 왜냐하면 람다식은 익명 구현 객체(익명 클래스)를 심플화 환것이고, 익명 클래스도 결국 클래스 참조 객체이니, 우리가 메소드에 참조 객체를 넘겨 메소드 내에서 객체의 메서드를 사용한 것처럼 별반 다르지 않다.
람다식 반환값 할당
일급 객체의 또다른 특징이라고 말할수 있는 메서드의 반환값을 람다함수 자체를 리턴하도록 지정 해줄 수 있다. 즉, 메서드의 리턴값이 메서드(함수)인 것이다.
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
IAdd func = makeFunction(); // 메소드의 반환값이 람다 함수
int result = func.add(1, 2);
System.out.println(result); // 3
}
public static IAdd makeFunction() {
return (x, y) -> x + y;
}
}
람다식 실전 예제
Thread 호출
자바의 쓰레드를 먼저 배우신 독자 분들이라면, 다음과 같은 코드 문법 구성에 대해 익숙할 것이다.
이는 신박한 문법이 아니고 결국은 자세히 보면 new Thread() 생성자 안에 매개변수로서 람다식을 넣은 것 뿐이다.
Thread thread = new Thread( () -> {
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
});
enum을 깔끔하게
enum 객체의 확장을 조금 더 간결하고 깔끔하게 만들 수도 있다.
enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x * y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
import java.util.function.DoubleBinaryOperator;
enum Operation {
PLUS("+", (x, y) -> x + y),
MINUS("-", (x, y) -> x - y),
TIMES("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override
public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
public class Main {
public static void main(String[] args) {
// 사용은 아래와 같이
Operation.PLUS.apply(2, 3);
}
}
람다식의 형변환
사실 람다식은 익명 객체이고 타입이 없다. 정확히 말하면 함수형 인터페이스로 람다식을 참조할 수 있을 뿐이다. 그래서 사실 인터페이스 타입의 변수에 람다식을 할당하는 행위는 캐스팅 연산자가 생략 되어 있다.
IAdd func = () -> {});
IAdd func = (IAdd) (() -> {}); // 원래는 양변의 타입이 다르므로 형변환이 필요
이러한 특징을 이용해 람다식을 모든 클래스의 최상위 클래스인 Object 클래스로 형변환이 가능하다. 이러한 특징이 있다는 정도만 알고 넘어가자.
interface IAdd {
int add(int x, int y);
}
public class Main {
public static void main(String[] args) {
IAdd lambda = (x, y) -> x + y;
System.out.println(lambda);
// Object 타입으로 업캐스팅하기 위해선 두번 캐스팅 해주어야 한다
Object lambda_obj = (Object) (IAdd) ((x, y) -> x + y);
System.out.println(lambda_obj);
}
}
람다 표현식의 한계
이처럼 람다표현식은 안그래도 길다란 자바 코드를 말끔히 줄이는데 상당한 일조를 하지만, 단점이나 사용하기에 적절치 못한 경우가 존재한다.
1. 람다는 문서화를 할 수 없다
람다 자체는 이름이 없는 함수이기 때문에 메서드나 클래스와 다르게 문서화를 할 수 없다. 그래서 코드 자체로 동작이 명확하게 설명되지 않거나 람다가 길거나 읽기 어렵다면, 쓰지 않는 방향으로 리팩토링하는 것을 고려해야 한다.
2. 람다는 디버깅이 다소 까다롭다
람다식은 기본적으로 익명 구현 객체 기반이기 때문에, 익명 객체 특성상 디버깅 할때 콜 스택(call stack) 추적이 매우 어려운 단점을 가지고 있다.
예를들어 0을 나누는 같은 오류가 나타나는 코드를 일반 for문과 람다식을 이용한 표현을 코드를 실행하면 다음 과 같이 에러 줄 서부터 확연히 다른걸 볼 수 있다.
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer i : list) {
for (int j = 0; j < i; j++) {
System.out.println(i / j);
}
}
}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
list.forEach(i -> {
IntStream.range(0, i).forEach(j -> {
System.out.println(i/j);
});
});
}
이는 람다가 내부적으로 수행하는 작업이 더 많기 때문에 발생하는 현상이기 때문에, 코드가 복잡해 질수록 어디에서 문제가 발생했는지 확인하기가 어려워지게 된다. 그리고 이는 곧 성능과 연결 되기 도 한다.
3. stream에서 람다를 사용할 시 for문 보다 성능이 떨어진다
어플리케이션 성능에 매우 민감한 사람이라면 치명적인 단점이 될 수 도 있다.
아래 예제는 0 부터 10000 까지 단순 순회하는 로직을 stream의 람다와 단순 for문으로 구성하고 각각 실행시간을 나노초로 구하는 코드이다. 결과를 보듯이 두 실행시간 차이는 결코 적지 않다.
public static void main(String[] args) {
// 람다식 stream 순회 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
long startTime = System.nanoTime(); // 코드 시작 시간
IntStream.range(0,10000).forEach((value) -> {});
long endTime = System.nanoTime(); // 코드 끝난 시간
long durationTimeSec = endTime - startTime;
System.out.println("람다식 stream 순회 : " + durationTimeSec + "n/s");
// 일반 for문 순회 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
startTime = System.nanoTime(); // 코드 시작 시간
for(int i=0; i<10000; i++){
}
endTime = System.nanoTime(); // 코드 끝난 시간
durationTimeSec = endTime - startTime;
System.out.println("일반 for문 순회 : " + durationTimeSec + "n/s");
}
4. 람다를 남발하면 코드가 지저분해질 수 있다
기존에는 동작 행위를 미리 클래스의 메서드로 정의해놓고 실행부에서 갖다 쓰는 것이었지만, 람다는 동작 행위를 실행부에서 지정하는 식이다. 그래서인지 람다식을 남발하다보면 비슷하게 생긴 함수를 계속 중복 생성하고 있는 자신을 발견 할 수 있다.
interface OperationStrategy {
// (int x, int y) -> int
int calculate(int x, int y);
}
// Template
class OperationTemplate {
int calculate(int x, int y, OperationStrategy cal) {
int result = cal.calculate(x, y);
return result;
}
}
public static void main(String[] args) {
int x = 100;
int y = 30;
OperationContext cxt = new OperationContext();
int result = cxt.calculate(x, y, (x1, y1) -> x1 + y1);
System.out.println(result); // 130
result = cxt.calculate(x, y, (x1, y1) -> x1 - y1);
System.out.println(result); // 70
result = cxt.calculate(x, y, (x1, y1) -> x1 * y1);
System.out.println(result); // 3000
result = cxt.calculate(x, y, (x1, y1) -> x1 / y1);
System.out.println(result); // 3
}
비스무리한 람다 함수를 메서드 아규먼트로 지정하고 있다. 위의 예제는 아주 간단한 예시라 와닿지 않을 수 있지만 람다식 로직이 두줄 세줄이 넘어간다면 실행부의 코드가 지저분해 질 수도 있다.
5. 재귀로 만들경우에는 다소 부적합하다
람다식을 통해 재귀 함수를 구축하면 실행 조차 안되는 컴파일 에러가 나타난다.
public static void main(String[] args) {
UnaryOperator<Long> factorial = (x) -> {
x == 0 ? 1 : x * factorial.apply(x - 1); // compile error
};
factorial(1);
}
# 참고자료
https://github.com/yeGenieee/java-live-study/blob/main/%5B15%5DJava%20Live%20Study.md
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.