람다란? (lambda)
- 익명
메서드에 이름이 없다.
람다는 메서드에 이름이 필요없기 때문에 익명 함수로 분류되며, 익명 함수는 모두 일급 객체로 취급된다. - 함수
특정 클래스에 종속되지 않기 때문에 함수라고 부를 수 있다.
하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다. - 전달
람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
이는 일급 객체의 특징과도 같다.
일급 객체로 취급되기 때문에 Stream API의 매개변수로 전달이 가능하다. - 간결성
익명 클래스처럼 판에 박힌 코드를 구현할 필요가 없다.
따라서 불필요한 코드를 줄이고 가독성을 높일 수 있다.
람다의 장점과 단점
장점
- 코드의 간결성
람다를 사용하면 불필요한 반복문의 삭제가 가능하며 복잡한 식을 단순하게 표현할 수 있다.
람다식에 개발자의 의도가 명확히 드러나기 때문에 코드의 가독성이 높아지는 것이다. - 생산성 향상
함수를 만드는 과정없이 한 번에 처리할 수 있어 생산성이 높아진다. - 지연연산
람다는 지연연산을 수행 함으로써 불필요한 연산을 최소화 할 수 있다. - 병렬처리
멀티쓰레드를 활용하여 병렬처리를 사용 할 수 있다.
단점
- 재사용 불가능
- 디버깅이 어렵다
- 재귀로 만들 경우에 부적합하다.
- 불필요하게 남발하면 비슷한 함수가 중복 생성되어 오히려 가독성을 떨어 뜨릴 수 있다.
그렇다면 람다 표현식을 사용하면 코드를 어떻게 바꿀 수 있다는 걸까?
기존 코드
Comparator<Apple> byWeight = new Comparator<Apple>() {
@Override
public int compare(Apple o1, Apple o2) {
return o1.getWeight().compareTo(o2.getWeight());
}
};
람다를 이용한 코드
Comparator<Apple> byWeight =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
코드가 훨씬 간결해졌다.
특히 익명 클래스의 Override 부분같은 판에 박힌 코드를 구현할 필요가 없음을 볼 수 있다.
람다 표현식 구성
람다 표현식은 파라미터, 화살표, 바디로 이루어진다.
- 파라미터 리스트
Comparator의 compare 메서드 파라미터(a1, a2) - 화살표
파라미터 리스트와 바디를 구분한다. - 람다 바디
두 Apple의 무게를 비교한다. 람다의 반환값에 해당하는 표현식이다.
유효한 람다 표현식 예시
1. (String s) -> s.length()
String 형식의 파라미터 하나를 가지며 int를 반환한다.
람다 표현식에는 return이 함축되어 있으므로 return 문을 명시적으로 사용하지 않아도 된다.
2. (Apple a) -> a.getWeight() > 150
Apple 형식의 파라미터 하나를 가지며 boolean을 반환한다.
3.
(int x, int y) -> {
System.out.println("Result:");
System.out.println(x+y);
}
int 형식의 파라미터 두 개를 가지며 리턴값이 없다. (void)
람다 표현식은 중괄호를 사용해서 여러 행의 문장을 포함할 수 있다.
주의할 점은 중괄호를 사용할 때는 return 이 함축되어 있지 않기 때문에 void가 아닌 경우, 명시적으로 return문을 작성해주어야 한다.
3.1
(int x, int y) -> {
System.out.println("x + y 수행");
return x + y;
}
int 형식의 파라미터 두 개의 합을 반환하는 람다식이다.
4. () -> 42
파라미터가 없으며 int 42를 반환한다.
5. (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())
Apple 형식의 파라미터 두개를 가지며 int를 반환한다.(두 사과의 무게 비교 결과. compareTo 함수의 반환형에 맞춰진다.)
함수형 인터페이스 (Functional Interface)
Java는 기본적으로 객체지향 언어이다.
따라서 순수함수를 기본적으로는 구현할 수 없기 때문에 인터페이스의 도움을 받아서 간접 구현한다.
이를 함수형 인터페이스라고 부르며 정확히 하나의 추상 메서드를 지정하는 인터페이스이다.
Supplier<Integer> integerSupplier = () -> 42;
위에서 보았던 42를 반환하는 람다식이다.
사실 Java에서 모든 람다식은 함수형 인터페이스를 반환한다.
() -> 42 라는 람다식도 Supplier 라는 함수형 인터페이스를 반환하는 것이다.
Supplier를 살펴보면 아래와 같다.
/**
* Represents a supplier of results.
*
* <p>There is no requirement that a new or distinct result be returned each
* time the supplier is invoked.
*
* <p>This is a <a href="package-summary.html">functional interface</a>
* whose functional method is {@link #get()}.
*
* @param <T> the type of results supplied by this supplier
*
* @since 1.8
*/
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
Supplier는 제네릭 T 형식을 반환하는 함수형 인터페이스이다.
오직 get()이라 불리는 추상 메서드 단 하나를 가지고 있으며 파라미터를 따로 받지 않는다.
함수형 인터페이스의 추상 메서드의 이름은 크게 중요하지 않다. (람다식 자체가 해당 메서드의 이름을 대신하기때문에 메서드 명을 통한 호출은 없기 때문이다.)
만약 커스텀 인터페이스를 만들 일이 있다면, 인터페이스의 이름에 어울리게 수행하는 역할을 표현하도록 메서드 명을 작성하자.
함수형 인터페이스 어노테이션인 @FunctionalInterface 는 단지 명시적으로 인터페이스를 함수형으로 사용할 수 있음을 보여준다. 추가로 어노테이션이 붙은 인터페이스가 함수형 인터페이스가 요구하는 조건(ex. 단 하나의 추상 메서드)을 위반한다면 컴파일에러를 통해 개발자에게 알려주는 역할을 한다.
Java가 제공하는 함수형 인터페이스
java.util.function 패키지에서 기본적으로 제공하는 함수형 인터페이스 몇 가지를 알아보자
1. Predicate<T>
predicate는 객체 T를 매개변수로 받아서 boolean 을 반환한다.
추상 메서드로 boolean test(T t); 를 가지고 있다.
@FunctionalInterface
public interface Predicate<T> {
/**
* Evaluates this predicate on the given argument.
*
* @param t the input argument
* @return {@code true} if the input argument matches the predicate,
* otherwise {@code false}
*/
boolean test(T t);
.
.
.
Predicate 인터페이스에는 디폴트 메서드로 and, or, negate, isEqual, not 이 존재한다.
아래와 같이 사용 가능하다.
Predicate<Integer> is10 = (Integer i) -> i == 10;
Predicate<Integer> is15 = (Integer i) -> i == 15;
Predicate<Integer> is10and15 = is10.and(is15);
이후에 다루겠지만 Predicate 인터페이스 객체를 stream의 filter 함수의 조건 인자로 넘겨줄 수 있다.
2. Consumer<T>
Consumer는 T객체를 받아서 void를 반환한다.
consumer라는 이름에 걸맞게 객체를 소비한다는 의미를 가지고 있다.
추상 메서드로 void accept(T t); 를 가지고 있다.
@FunctionalInterface
public interface Consumer<T> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);
예를 들어 Integer 리스트를 인수로 받아서 각 항목에 어떤 동작을 수행하는 forEach 메서드를 실행할 때 Consumer를 활용할 수 있다.
Consumer<Integer> integerConsumer = (Integer i) -> System.out.println("i = " + i)
Arrays.asList(1,2,3,4,5).forEach(integerConsumer);
3. Supplier<T>
Supplier는 아무런 인자도 받지 않고 T객체를 생성해준다.
supplier라는 이름에 걸맞게 객체를 공급해준다는 의미를 가지고 있다.
추상 메서드로 T get(); 을 가지고 있다.
@FunctionalInterface
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
4. Function<T, R>
Function은 T객체를 받아서 R객체를 반환해준다.
T객체를 임의로 가공해서 R객체로 만드는 함수의 역할을 한다고 function 이라는 이름을 가진다.
추상 메서드로 R apply(T t); 를 가지고 있다.
@FunctionalInterface
public interface Function<T, R> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
Function은 꽤나 중요한데 이후에 Stream API에서 map 함수의 인자로 Function 객체를 넘길 수 있다.
아래는 String 리스트를 각 원소의 길이인 int 로 치환하는 코드이다.
Function<String, Integer> getStringLength = (String s) -> s.length();
Arrays.asList("scala", "lambdas", "test", "design pattern", "java").stream()
.map(getStringLength);
활용 : 실행 어라운드 패턴
람다의 특성을 활용해서 확장성을 높이고 간결한 코드로 바꾸는 방법
어떤 비즈니스 로직이 다음과 같은 구성으로 수행된다면 람다식을 적용할 가치가 충분히 있다고 할 수 있다.
- 자원 요청 및 허가, 열람
- 자원 처리
- 자원 회수, 종료
위 과정에서 1, 3번 과정은 대부분 비슷할 것이다.
자원을 열어서 어떻게 가공하고 처리할지가 중요한 부분인데, 이 부분을 람다 표현식으로 넘길 수 있다.
이전 코드
public String readOneLineFromFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine();
}
}
public String readTwoLineFromFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return (br.readLine() + br.readLine());
}
}
public String readAllLineFromFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
String lines = "";
String str = "";
while((str = br.readLine()) != null) {
lines += str;
str = "";
}
return lines;
}
}
파일로부터 한 줄을 읽는 메서드, 두 줄을 읽는 메서드, 모든 줄을 읽는 메서드를 따로따로 구현한 모습이다.
이 코드는 람다를 사용해서 동작을 전달하는 방식으로 수정할 수 있다.
바뀐 코드
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
public String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return p.process(br);
}
}
1. BufferedReader 객체를 받아서 String 을 반환하는 함수형 인터페이스를 생성한다.
2. processFile 이라는 범용적인 이름의 메서드를 만들고 메서드의 인자로 생성한 함수형 인터페이스를 받도록 한다.
3. return 부에 함수형 인터페이스의 process 메서드를 실행하도록 한다. (람다식을 실행한다.)
람다 전달
BufferedReaderProcessor oneLine = (BufferedReader br) -> br.readLine();
BufferedReaderProcessor twoLine = (BufferedReader br) -> br.readLine() + br.readLine();
BufferedReaderProcessor allLine = (BufferedReader br) -> {
String lines = "";
String str = "";
while ((str = br.readLine()) != null) {
lines += str;
str = "";
}
return lines;
};
String one = processFile(oneLine);
String two = processFile(twoLine);
String all = processFile(allLine);
oneLine, twoLine, allLine 객체를 만들어서 processFile의 인자로 넘김을 볼 수 있다.
processFile의 재사용성이 높아지고 새로운 전략을 생성할 때 새로운 람다식만 하나 만들면 되므로 확장성도 높아지게 되었다.
Ref.
'Lang > Java' 카테고리의 다른 글
옵저버 패턴 (Observer Pattern) (3) | 2022.10.21 |
---|---|
전략 패턴 (Strategy Pattern) (3) | 2022.10.04 |
함수형 프로그래밍과 Java #1 (6) | 2022.09.08 |
[디자인 패턴] 싱글톤 패턴 (Creational) (0) | 2022.01.10 |
[Java] Runnable과 Thread의 차이 (0) | 2021.12.11 |