남궁성 저자의 Java의 정석 3판 스터디
자바는 자바8 (JDK1.8) 에서 람다식이 추가되면서
객체지향언어인 동시에 함수형 언어가 되었다.
람다식에 대해 알아보자 😊
람다식이란 ?
람다식은 간단히 말해 method 를 하나의 식(expression)으로 표현한 것
이다.
method 를 람다식으로 표현하면 method의 이름과 반환값이 사라지므로,
람다식을 익명함수(anonymous function)
이라고도 한다.
int[] arr = new int[5];
Arrays.setAll(arr, (i) -> (int)(Math.random() * 5) + 1);
// 위 람다식을 method 로 표현하면 아래와 같다.
int method() {
return (int)(Math.random() * 5) + 1;
}
method 와 function 의 차이 ?
객체지향에서는 객체의 행위나 동작을 의미하는 method 라는 용어 사용.
method는 특정 클래스에 반드시 속해야 한다는 제약이 있지만,
람다식을 통해 method 가 하나의 독립적인 기능을 하기 때문에 함수라고 할 수 있다.
반환타입 메서드이름(매개변수) {
...
}
// 위 메서드가 아래 람다식이 된다 !
(매개변수) -> { ... }
예를 들어 두 값 중에서 큰 값을 반환하는 메서드를 람다식으로 표현하면
int max(int a, int b) {
return a > b ? a : b;
}
// 아래는 람다식
(int a, int b) -> { return a > b ? a : b; }
// 식의 연산결과가 자동으로 반환값이 되므로 return 생략 가능, 괄호 생략 가능
(int a, int b) -> a > b ? a : b
// 단 괄호 안의 문장이 return 문이면 괄호 생략 X
(int a, int b) -> return a > b ? a : b // 에러
// 매개변수 타입이 추론 가능한 경우, 타입도 생략 가능
(a, b) -> a > b ? a : b
// 선언된 매개변수가 하나이면 괄호도 생략 가능
a -> a * a
// 단 매개변수 타입이 있으면 괄호 생략 X
int a -> a * a // 에러
(int a) -> a * a // OK
자바에서 모든 method 는 클래스 안에 포함되어야 하는데
람다식은 익명 클래스의 객체와 동등하다
(int a, int b) -> a > b ? a : b
// 아래와 같은 구조 !
new Object() {
int max(int a, int b) {
return a > b ? a : b;
}
}
이 익명 객체의 주소를 참조 변수에 저장해보자.
먼저 위의 max() 라는 method 가 정의된 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);
위에서 max()는 앞서 살펴본 람다식과 일치 !
위 코드의 익명 객체를 아래와 같이 람다식으로 대체할 수 있다.
MyFunction f = (int a, int b) -> a > b ? a : b;
int big = f.max(5, 3);
람다식도 실제 익명 객체이고, MyFunction 인터페이스를 구현한 익명 객체의 method max() 와
람다식의 매개변수 타입, 개수, 그리고 반환값이 일치하기 때문에 대체가 가능하다 ‼️
이렇게 인터페이스를 통해 람다식을 다루기 위한 인터페이스를
함수형 인터페이스
라고 부른다.
함수를 변수처럼 선언할 수 있다 ‼️
@FunctionalInterface
interface MyFunction {
public abstract int max(int a, int b);
}
함수형 인터페이스에는 오직 하나의 추상 메소드만 정의되어 있어야 한다 ‼️
그래야 람다식과 인터페이스의 메소드가 1:1 로 연결될 수 있기 때문 !
반면에 static 메소드와 default 메소드의 개수에는 제약이 없다.
@FunctionalInterface 를 붙이면, 함수형 인터페이스가 올바르게 정의되었는지
컴파일러가 확인해준다. 꼭 붙이자 !
예를 들어 인터페이스의 메소드를 하나 구현할 때도 아래처럼 복잡하게 했어야 했다.
List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd");
Collections.sort(list, new Comparator<String>() {
public int compare(String s1, String s2) {
return s2.compareTo(s1);
}
};
이제 람다식으로 간단히 처리할 수 있게 되었다 ‼️
List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd");
Collection.sort(list, (s1, s2) -> s2.compareTo(s1));
java.util.function
패키지에 자주 쓰이는 형식의 메소드를 함수형 인터페이스로 미리 정의해놓았다.
가능하면 이 패키지의 인터페이스를 활용하는 것이 좋다 !
java.lang.Runnable | void run() | 매개변수 X, 반환값 X |
Supplier | T get() | 매개변수 X, 반환값 O |
Consumer | void accept(T t) | 매개변수 O, 반환값 X |
Function<T, R> | R apply(T t) | 매개변수 O, 반환값 O |
Predicate | boolean test(T t) | 조건식을 표현할 때 사용, 매개변수 하나, 반환타입은 boolean |
매개변수와 반환값의 유무에 따라 4개의 함수형 인터페이스가 정의되어 있고,
Function 의 변형으로 Predicate 가 있는데, 반환값이 boolean 이라는 것만 제외하면
Function 과 동일하다. Predicate 는 조건식을 함수로 표현하는데 사용된다 !
위 인터페이스를 가져다 사용하는 예제는 아래와 같다.
Predicate<String> isEmptyStr = s -> s.length() == 0;
String s = "";
if(isEmptyStr.test(s))
System.out.println("Empty String.");
매개변수의 개수가 2개인 함수형 인터페이스도 있는데, 이름 앞에 접두사 Bi 가 붙는다.
이 글에선 생략하고 넘어가겠다 😊 (쓸 때 찾아보자)
두 개 이상의 매개변수를 갖는 함수형 인터페이스가 필요하다면 직접 만들어서 써야한다.
만약에 3개의 매개변수를 가지는 함수형 인터페이스를 선언한다면 아래와 같을 것이다.
@FunctionalInterface
interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
이전 글에서 살펴보았던 컬렉션 프레임워크의 인터페이스에 디폴트 메소드가 추가되었는데,
그 중에 일부는 함수형 인터페이스를 사용한다.
Collection | boolean removeIf(Predicate filter) | 조건에 맞는 요소를 삭제 |
List | void replaceAll(UnaryOperator operator) | 모든 요소를 변환하여 대체 |
Iterable | void forEach(Consumer action) | 모든 요소에 작업 action을 수행 |
Map | V compute(K key, BiFunction<K,V,V> f) | 지정된 키의 값에 f 수행 |
V computeIfAbsent(K key, Function<K,V> f) | 키가 없으면 f 수행 후 추가 | |
V computeIfPresent(K key, BiFunction<K,V,V> f) | 지정된 키가 있을 때, f 수행 | |
V merge(K key, V value, BiFunction<K,V,V> f) | 모든 요소에 병합작업 f 수행 | |
void forEach(BiConsumer<K,V> action) | 모든 요소에 작업 action 수행 | |
void replaceAll(BiFunction<K,V,V> f) | 모든 요소에 치환작업 f 수행 |
코드를 통해 어떤 느낌인지만 익히고 넘어가자.
ArrayList<Integer> list = new ArrayList<>();
for(int i=0; i < 10; i++) {
list.add(i);
}
// list 모든 요소 출력
list.forEach(i -> System.out.print(i + ","));
System.out.println();
// list 에서 2 or 3의 배수 제거
list.removeIf(x -> x%2 == 0 || x%3 == 0);
System.out.println(list);
// list 의 각 요소에 10을 곱하기
list.replaceAll(i -> i*10);
System.out.println(list);
Map<String, String> map = new HashMap<>();
map.put("1", "1");
map.put("2", "2");
map.put("3", "3");
map.put("4", "4");
// map 의 모든 요소를 {k,v} 형태로 출력
map.forEach((k,v) -> System.out.print("{" + k + "," + v + "}"));
여기서 소개하진 않지만 래퍼 클래스가 아닌 기본형 타입의 값을 처리하는
함수형 인터페이스들도 있다.
IntFunction<R>
, DoubleToIntFunction
같은 것들이 그런 인터페이스들이다.
Method 참조
람다식이 하나의 메소드만 호출하는 경우에는
메소드 참조 (method reference)
라는 방법으로 간략히 할 수 있다.
예를들어 문자열을 정수로 변환하는 람다식은 아래와 같다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
// 위 람다식을 메소드로 표현하면
Integer wrapper(String s) { // 메소드 이름은 의미 X
return Integer.parseInt(s);
}
wrapper 메소드는 값을 받아서 parseInt 에게 넘겨주는 역할밖에 하지 않는다.
그럴 땐 이렇게 변경할 수 있다.
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
// 위 람다식을 아래처럼 변경 !
Function<String, Integer> f = Integer::parseInt;
람다식의 일부가 생략됐지만, 컴파일러는 생략된 부분을 우변의 parseInt 메소드의 선언부로부터,
또는 좌변의 Function 인터페이스에 지정된 제네릭 타입으로부터 알아낼 수 있다.
예시로 아래 람다식을 메소드 참조로 바꿔보자 !
BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);
// 위 람다식을 메소드 참조로 바꾸면 ?
BiFunction<String, String, Boolean> f = String::equals;
이미 생성된 객체의 메소드를 람다식에서 사용한 경우에도 사용할 수 있는데
클래스 이름 대신 그 객체의 참조변수를 적어주면 된다 !
MyClass obj = new MyClass();
Function<String, Boolean> f = (x) -> obj.equals(x); // 람다식
Function<String, Boolean> f2 = obj::equals; // 메소드 참조
요약하자면
하나의 메소드만 호출하는 람다식은 "클래스이름::메소드이름"" 또는
"참조변수::메소드이름" 으로 바꿀 수 있다 !
다음 글에서는 스트림에 대해서 살펴보자 😊