고코딩
[JAVA] 람다는 무엇일까? 본문
이 글은 모던 자바 인 액션
책 내용을 정리한 내용입니다.
람다 표현식은 무엇일까?
람다 표현식은 메서들 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다. 람다 표현식에는 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있다.
익명
보통의 메서드와 달리 이름이 없으므로 익명이라 표현한다.
함수
람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.
전달
람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다. (함수형 프로그래밍이랑 비슷한 특징이다.)
간결성
익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.
묻지도 따지지도 말고 예제 코드를 하나 들어보자 예를 들어 Comparator 객체를 기존보다 간단하게 구현할 수 있다. 참고로 여기서 사용하는 Apple
클래스는 맨 밑에 코드로 적언 놓았으니 참고.
Comparator<Apple> byWeight = new Comparator<Apple>() {
@Override
public int compare(Apple a1, Apple a2) {
return a1.getWeight()-a2.getWeight();
}
};
다음은 람다를 이용한 코드이다.
Comparator<Apple> byWeight = (Apple a1, Apple a2) -> a1.getWeight()-a2.getWeight();
코드가 확실히 간단해졌다.
(Apple a1, Apple a2)
: 람다 파라미터 리스트->
: 화살표a1.getWeight()-a2.getWeight();
: 람다 바디
5줄 짜리 코드가 1줄로 변화했다. 그리고 가독성도 높아졌다. Apple
객체를 2개 받아 서로의 값을 빼서 리턴하는 의미이다.
람다에서 중요한 개념은 동작을 파라미터화 시킬 수 있다는 것이다.
함수형 인터페이스
그럼 모든 메소드에 대해서 다 람다형식으로 바꿀수 있는 것일까? 아니다. 람다는 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.
함수형 인터페이스는 정확히 하나의 추상 메서드를 지정하는 인터페이스다. 이제부터 나올 대부분의 람다형식은 함수형 인터페이스를 표현한 것이다.
public interface Comparator<T>{
int compare(T o1, T o2);
}
public interface Runnable{
void run();
}
public interface Callable<V>{
V call() throws Exception;
}
인터페이스를 implements해서 구현하려면 추상메서드를 반드시 구현해야 한다. 람다는 추상메서드를 익명함수로 구현한 것이라고 생각하면 된다. 정확히 말하면 람다표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스터스로 취급 할 수 있다.(기술적으로 보면 함수형 인터페이스를 구현한 클래스의 인스턴스)
Runnable을 이용한 람다 예제코드를 봐보자
Runnable r1 = () -> System.out.println("Hello World 1");
Runnable r2 = new Runnable(){
public void run() {
System.out.println("Hello World 2");
}
}
public static void process(Runnable r){
r.run();
}
process(r1);
process(r2);
process(()->System.out.println("Hello World 3"));
위 코드를 실행하면
Hello World 1
Hello World 2
Hello World 3
으로 출력되게 된다. r2
객체는 기존에 우리가 사용하던 익명 객체로 run()
메소드를 정의하였다.
r1
은 람다를 사용해서 run()
메소드에 표현식을 전달한 것이고,
r3
는 process()
메소드에 파라미터로 람다 표현식을 직접 전달한 것이다. 이 처럼 함수형 인터페이스의 메소드를 전달하는 방식과 그 규칙이 람다식이다.
전체적으로 람다식을 기존에 작성하던 코드와 비교해 보면 많이 생략되었지만 보고 이해하기는 쉬워졌을 것이다. 그것이 람다의 가장 큰 장점이다.
자바 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다. 지금은 Predicate, Consumer, Function 인터페이스를 설명하겠다.
Predicate
java.util.function.Predicate<T>
인터페이스는 test라는 추상 메서드를 정의하며 test는 제네릭 형식 T의 개체를 인수로 받아 boolean
을 반환한다. 우리가 만들었던 인터페이스와 같은 형태인데 따로 정의할 필요 없이 바로 사용할 수 있다는 점이 특징이다. T 형식의 객체를 사용하는 불리언 표현식이 필요한 상황에서 Predicate 인터페이스를 사용할 수 있다.
@FunctionalInterface
public interface Predicate<T>{
boolean test(T t);
}
public <T> List<T> filter(List<T> list, Predicate<T> p){
List<T> results = new ArrayList<>();
for(T t : list){
if(p.test(t)){
results.add(t);
}
}
return results;
}
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty(); //test()메소드를 구현
List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate); //nonEmptyStringPredicate를 파라미터로 전달
Consumer
java.util.funcction.Consumer<T>
인터페이스는 제네릭 형식 T 객체를 받아서 void
를 반환하는 accept라는 추상 메서드를 정의한다. T 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다. 예를 들어 Integer리스트를 인수로 받아서 각 항목에 어떤 동작을 수행하고 싶을때 Consumer를 활용할 수 있다.
@FunctionalInterface
public interface Consumer<T>{
void accept(T t);
}
public <T> void forEach(List<T> list, Consumer<T> c){
for(T t:list){
c.accept(t); //accept(t)는 System.out.println(t)로 실행됨
}
}
forEach(Arrays.asList(1,2,3,4,5), (Integer i) -> System.out.println(i)); //Consumer인터페이스의 accept()를 람다식으로 표현해서 파라미터로 전달.
Function
java.util.function.Function<T, R>
인터페이스는 제네릭 형식 T를 인수로 받아서 제네릭 형식 R객체
를 반환하는 추상 메서드 apply를 정의한다. 입력을 출력으로 매핑하는 람다를 정의할 때 Function 인터페이스를 활용할 수 있다. (return 값이 있는 메서드를 구현해야 할때 사용할 수 있다.)
@FunctionalInterface
public interface Function<T,R>{
R apply(T t);
}
public <T, R> List<R> map(List<T> list, Function<T, R> f){
List<R> result = new ArrayList<>();
for(T t:list){
result.add(f.apply(t));
}
return result;
}
List<Integer> list = map(Arrays.asList("Lamdas", "in", "action"),
(String s) -> s.length()); //Function인터페이스의 apply()메서드를 람다표현식으로 파라미터화 해서 넘김
제네릭 파라미터에는 형식이 참조형(Byte, Integer, Object, List, Double)만 사용할 수 있다. 다행히 자바에서는 기본형 -> 참조형
으로 변환하는 기능을 제공한다. 이 기능을 박싱, 언박싱 이라고 한다. 그리고 프로그래머가 편리하게 코드를 구현할 수 있도록 오토박싱 기능까지 있다.
하지만 이런 변환 과정은 많은 비용이 소모된다. 박싱한 값은 기본형을 감싸는 래퍼이며 힙에 저장된다. 따라서 박싱한 값은 메모리를 더 소비하여 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요하다.
만약 Predicate<Integer>
를 사용하게 된다면 오토박싱 동작이 발생할 수 밖에 없다. 자바 8 에서는 이러한 오토박싱을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.
public interface IntPredicate {
boolean test(int t);
}
IntPredicate evenNumbers = (int i) -> i% 2 == 0;
evenNumbers.test(1000); //박싱이 이뤄지지 않는다.
일반적으로 특정 형식을 입력으로 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate
,IntConsumer
, LongBinaryOperator
처럼 형식명이 붙는다. 인터페이스의 이름을 보고 적절한 함수형 인터페이스를 람다식으로 구현할 수 있을 것이다.
물론 우리가 직접 함수형 인터페이스를 만들어 낼 수도 있다. 함수형 인터페이스 조건(구현해야 할 메소드가 1개)만 만족하면 함수형인터페이스이다. @FunctionalInterface
를 붙여서 작성하면 함수형 인터페이스로 작성했는지 검사해준다.
메서드 참조
메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 때로는 람다 표현식보다 메서드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다.
그럼 메서드 참조가 왜 중요할까? 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다. 예를 들어 람다가 '이 메서드를 직접 호출해' 라고 명령한다면 메서드를 어떻게 호출해야 하는지 설명을 참조하기보다는 메서드명을 직접 참조하는 것이 편리하다. 메서드는 어떻게 참조할 수 있을까? 메서드명 앞에 구분자(::)를 붙이는 방식으로 메서드 참조를 활용할 수 있다. 예제를 보자
람다 | 메서드 참조 단축 표현 |
---|---|
(Apple apple) -> apple.getWeight() |
Apple::getWeight |
() -> Thread.currentThread().dumpStack |
Thread.currentThread()::dumpStack |
(str, i)-> str.substring(i) |
String::substring |
(String s) -> System.out.println(s) |
System.out::println |
(String s) -> this.isValidName(s) |
this::isValidName |
처음 볼 때는 많이 헷갈릴 것이다. 하지만 기존의 코딩 방법에서 최대한 생략해서 표현했다고 생각하면 된다. 예를 들어 System.out.println(s)
는 어떤 파라미터가 들어와도 결국에는 화면에 출력하라는 한가지 행동으로 추론할 수 있다. 파라미터는 컴파일할때 추론될 것이므로 메서드명만 써줌으로써 가독성을 높여준다.
메서드 참조를 만드는 방법은 3가지 유형으로 구분할 수 있다.
정적 메소드 참조
예를 들어 Integer의 parseInt 메서드는
Integer::parseInt
로 표현할 수 있다.다양한 형식의 인스턴스 메서드 참조
예를 들어 String의 length 메서드는
String::length
로 표현할 수 있다.기존 객체의 인스턴스 메서드 참조
예를 들어 Transaction 객체를 할당받은 expensiveTransaction 지역 변수가 있고, Transaction 객체에는 getValue 메서드가 있다면, 이를
expensiveTransaction::getValue
라고 표현할 수 있다.
생성자 참조
ClassName::new
처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다. 예를 들어
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();
get()
메서드는 Apple객체를 리턴해주는 역할을 한다. 위 코드를 람다식으로 표현해보면 아래와 같다.
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();
만약 Apple(Integer weight)을 갖는 생성자를 참조하고 싶다면 Function 함수 인터페이스로 구현할 수 있다.
Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(10);
이 코드를 람다로 표현하면 아래와 같다.
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(10);
마치며
람다 형식의 표현은 처음보는 사람에게는 정말 생소하고 어렵다. 나도 이해하는데 오래걸렸고 수많은 글을 보면서 이해하려고 노력했다. 하지만 결국 실제 코드로 쳐 보면서 사용을 해보는 것이 람다를 익힐 수 있는 가장 좋은 방법이라고 생각한다. 나도 이 글을 쓰면서 람다에 대해서 완벽하게 설명하지 못 했다. 내가 이해하고 있는 람다의 지식을 글로 쓰려니 여간 어려운게 아니였다. 하지만 코드를 쓰다 보면 나도 모르게 자연스럽게 람다를 사용하는 모습을 발견할 수 있을 것이다. 왜냐면 람다는 개발자들의 가독성을 위해서 나왔기 때문이다. 최대한 람다식을 많이 쓰려고 해보자.
public class Apple{
private int weight = 0;
private Color color;
public Apple(int weight, Color color) {
this.weight = weight;
this.color = color;
}
public int getWeight() {
return weight;
}
public void setWeight(int weight) {
this.weight = weight;
}
public Color getColor() {
return color;
}
public void setColor(Color color) {
this.color = color;
}
@SuppressWarnings("boxing")
@Override
public String toString() {
return String.format("Apple{color=%s, weight=%d}", color, weight);
}
}
'JAVA' 카테고리의 다른 글
[JAVA] JNDI란? (0) | 2021.05.26 |
---|---|
스트림 실습 문제 8개(매우 기초) (0) | 2021.05.14 |
[JAVA] Collection 관계 (0) | 2021.04.07 |
[JAVA] JDK JRE JVM 관계 (0) | 2021.04.05 |
[JAVA] UUID의 개념과 간단 사용법 (0) | 2021.02.22 |