람다식(Lambda expression)
람다식의 도입으로 인해, 자바는 객체지향언어인 동시에 함수형 언어가 되었다. 객체지향언어가 함수형 언어의 기능까지 갖추게 하는 일은 결코 쉬운 일이 아니었을텐데도 기존의 자바를 거의 변경하지 않고도 함수형 언어의 장점을 잘 접목시키는데 성공했다.
람다식이란?
메서드를 하나의 '식'으로 표현한 것이다. 람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.
메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 '익명 함수'이라고도 한다.
int[] arr = new int[5];
Arrays.setAll(arr, (i) -> (int)(Math.random()*5)+1);
위의 문장에서 람다식 '(i) -> (int)(Math.random()*5)+1' 이 하는 일을 메서드로 표현하면 다음과 같다.
int method() {
return (int)(Math.random()*5) + 1;
}
람다식은 메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수 있다. 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해졌다.
람다식 작성하기
'익명 함수'답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통{} 사이에 '->'를 추가한다.
반환타입 메서드이름(매개변수 선언) {
문장들
}
↓
(매개변수 선언) -> {
문장들
}
예를 들어 두 값 중에서 큰 값을 반환하는 메서드 max를 람다식으로 변환하면,
int max(int a, int b) {
return a > b ? a : b;
}
// 람다식 변환
(int a, int b) -> {
return a > b ? a : b;
}
// 람다식 변환
(int a, int b) -> { return a > b ? a : b; }
// 람다식 변환
(a, b) -> a > b ? a : b
함수형 인터페이스
예를 들어 max()라는 메서드가 정의된 MyFunction 인터페이스가 정의되어 있다고 가정하자.
interface MyFunction {
public abstract int max(int a, int b);
}
그러면 이 인터페이스를 구현한 익명 클래스의 객체는 다음과 같이 생성할 수 있다.
MyFunction f = new MyFunction() {
public int max(int a, int b) {
return a > b ? a : b;
}
};
int big = f.max(5, 3); // 익명 객체의 메서드를 호출
위 코드의 익명 객체를 람다식으로 아래와 같이 대체할 수있다.
MyFunction f = (int a, int b) -> a > b ? a : b; // 익명 객체를 람다식으로 대체
int big = f.max(5, 3); // 익명 객체의 메서드를 호출
람다식의 타입과 형변환
함수형 인터페이스로 람다식을 참조할 수 있는 것일 뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 람다식은 익명 객체이고 익명 객체는 타입이 없다. 정확히는 타입은 있지만 알 수 없다.
그래서 대입 연산자의 양변의 타입을 일치시키기 위해 아래와 같이 형변환이 필요하다.
MyFunction f = (MyFunction) (()->{}); // 양변의 타입이 다르므로 형변환이 필요
람다식은 이름이 없을 뿐 분명히 객체인데도, 아래와 같이 Object 타입으로 형변환 할 수 없다.
람다식은 오직 함수형 인터페이스로만 형변환이 가능하다.
Object obj = (Object)(()->{}); // 에러. 함수형 인터페이스로만 형변환 가능
메서드 참조
람다식으로 메서드를 간결하게 표현할 수 있는데 람다식을 더욱 간결하게 표현하는 방법이 있다. 람다식이 하나의 메서드만 호출하는 경우에는 '메서드 참조'라는 방법으로 람다식을 간략히 할 수 있다.
예를 들어 문자열을 정수로 변환하는 람다식은 아래와 같다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
// 해당 람다식을 메서드로 표현
Integer wrapper(String s) { // 이 메서드의 이름은 의미없다.
return Integer.parseInt(s);
}
거추장스러운 메서드를 벗겨내고 Integer.parseInt()를 직접 호출한다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
↓
Function<String, Integer> f = Integer::parseInt; // 메서드 참조
하나의 메서드만 호출하는 람다식은
'클래스이름::메서드이름' 또는 '참조변수::메서드이름'으로 바꿀 수 있다.
생성자를 호출하는 람다식도 메서드 참조로 변환할 수 있다.
Supplier<MyClass> s = () -> new MyClass(); // 람다식
Supplier(MyClass> s = MyClass::new; // 메서드 참조
매개변수가 있는 생성자
Function<Integer, MyClass> f = (i) -> new MyClass(i); // 람다식
Function<Integer, MyClass> f2 = MyClass::new; // 메서드 참조
배열 생성
Function<Integer, int[]> f = x -> new int[x]; // 람다식
Function<Integer, int[]> f2 = int[]::new; //메서드 참조
메서드 참조는 람다식을 마치 static 변수처럼 다룰 수 있게 해준다.
스트림(stream)
지금까지 많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 Iterator를 이용해서 코드를 작성했다. 그러나 이런 방식은 코드가 너무 길고 알아보기 어렵다. 그리고 재사용성도 떨어진다.
또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 것이다. 예를 들어 List를 정렬할 때는 Collections.sort()를 사용해야하고, 배열을 정렬할 때는 Arrays.sort()를 사용해야 한다.
이러한 문제점들을 해결하기 위해 만든 것이 '스트림'이다. 스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다.
데이터 소스를 추상화하였다는 것은, 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다.
스트림을 이용하면, 배열이나 컬렉션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.
예를들어, 문자열 배열과 같은 내용의 문자열을 저장하는 List가 있을 때,
String[] strArr = {"aaa", "bbb", "ccc"};
List<String> strList = Arrays.asList(strArr);
이 두 데이터 소스를 기반으로 하는 스트림은 다음과 같이 생성한다.
Stream<String> strStream1 = strList.stream(); // 스트림을 생성
Stream<String> strStream2 = Arrays.stream(strArr); // 스트림을 생성
이 두 스트림으로 데이터 소스의 데이터를 읽어서 정렬하고 화면에 출력하는 방법은 다음과 같다. 데이터 소스가 정렬되는 것은 아니라는 것에 유의하자.
strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);
스트림을 사용한 코드가 간결하고 이해하기 쉬우며 재사용성도 높다는 것을 알 수 있다.
스트림의 연산
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다.
스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있는데, 중간 연산은 연산 결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면에 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.
중간 연산 : 연산 결과가 스트림인 연산. 스트림에 연속해서 중간 연산할 수 있음
최종 연산 : 연산 결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 단 한번만 가능
stream.distinct().limit(5).sorted().forEach(System.out::println)
중간연산 중간연산 중간연산 최종연산
스트림 만들기
컬렉션
Stream<T> Collection.stream()
예를 들어 List로부터 스트림을 생성하는 코드는 다음과 같다.
List<Integer> list = Arrays.asList(1,2,3,4,5); // 가변인자
Stream<Integer> intStream = list.stream(); // list를 소스로 하는 컬렉션 생성
배열
문자열 스트림은 다음과 같이 생성한다.
Stream<String> strStream = Stream.of("a", "b", "c"); // 가변인자
Stream<String> strStream = Stream.of(new String[] {"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[] {"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3);
변환 - map()
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다. 이 때 사용하는 것이 바로 map()이다. 매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야한다.
Stream<R> map(Function<? super T,? extends R> mapper)
조회 - peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면, peek()를 사용. forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러 번 끼워 넣어도 문제가 되지 않는다.
스트림의 최종 연산
최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산 후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다.
forEach()
forEach()는 peek()과 달리 스트림의 요소를 소모하는 최종연산이다. 반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용된다.
void forEach(Consumer<? super T> action)
collect()
스트림의 최종 연산중에서 가장 복잡하면서도 유용하게 활용될 수 있는 것이 collect()이다.
- collect() : 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
- Collector : 인터페이스, 컬렉터는 이 인터페이스를 구현해야한다.
- Collectors : 클래스, static 메서드로 미리 작성된 컬렉터를 제공한다.
Collector 구현하기
컬렉터를 작성한다는 것은 Collector 인터페이스를 구현한다는 것을 의미한다.
Collector 인터페이스는 다음과 같이 정의되어 있다.
public interface Collector<T, A, R> {
supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics(); // 컬렉터의 특성이 담긴 Set을 반환
...
}
직접 구현해야하는 것은 위의 5개의 메서드인데, characteristics()를 제외하면 모두 반환 타입이 함수형 인터페이스이다. 즉 4개의 람다식을 작성하면 되는 것이다.
supplier() - 작업 결과를 저장할 공간을 제공
accumulator() - 스트림의 요소를 수집(collect) 할 방법을 제공
combiner() - 두 저장공간을 병합할 방법을 제공(병렬 스트림)
finisher() - 결과를 최종적으로 변환할 방법을 제공
public Function finisher() {
return Function.identity(); // 항등 함수를 반환. return x -> x; 와 동일
}
characteristics()는 컬렉터가 수행하는 작업의 속성에 대한 정보를 제공하기 위한 것이다.
Characteristics.CONCURRENT - 병렬로 처리할 수 있는 작업
Characteristics.UNORDERED - 스트림의 요소의 순서가 유지될 필요가 없는 작업
Characteristics.IDENTITY_FINISH - finisher()가 항등 함수인 작업
위의 3가지 속성 중에서 해당하는 것을 다음과 같이 Set에 담아서 반환하도록 구현하면된다.
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of (
Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED
));
}
아무런 속성도 지정하고 싶지 않으면, 아래와 같이 하면 된다.
Set<Characteristics> characteristics() {
return Collections.emptySet(); // 지정할 특성이 없는 경우 비어있는 Set을 반환
}
'자바 > Java의 정석' 카테고리의 다른 글
| [Chapter 16] 네트워킹 (0) | 2024.12.05 |
|---|---|
| [Chapter 15] 입출력 (1) | 2024.12.04 |
| [Chapter 13] 쓰레드 (2) | 2024.11.30 |
| [Chapter 12] 지네릭스, 열거형, 애너테이션 (1) | 2024.11.26 |
| [Chapter 11] 컬렉션 프레임웍 (1) | 2024.11.26 |