[Java] 람다 표현식(2)

🏃‍♀️·2023년 8월 25일

Java [이론]

목록 보기
10/14

형식 검사, 형식 추론, 제약

람다로 함수형 인터페이스의 인스턴스를 만들 수 있다고 했다. 람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다. 따라서 람다를 더 잘 이해하기 위해 람다의 실제 형식을 파악해야한다.


형식 검사

람다가 사용되는 콘텍스트를 이용해 람다의 형식을 추론할 수 있다. 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라고 부른다. 다음 예제를 통해 람다 표현식을 사용할 때 실제 어떤 일이 일어나는 지 확인하자.

List<Pencil> longerThan25 = filter(inventory,(Pencil pencil) -> pencil.getLength() > 25);
  1. filter 메소드의 선언을 확인하다.
  2. filter 메소드는 두 번째 파라미터로 Predicate<Pencil> 형식(대상형식)을 기대한다.
  3. Predicate<Pencil>은 test라는 한 개의 추상 메소드를 정의하는 함수형 인터페이스다.
  4. test 메소드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
  5. filter 메소드로 전달된 인수는 이와 같은 요구사항을 만족해야한다.
    함수디스트립터와 람다의 시그니처 일치 여부, 예외 처리 일치 여부


같은 람다, 다른 함수형 인터페이스

대상 형식이라는 특징 때문에 같은 람다 표현식이더라도 호환되는 추상 메소드를 가진 다른 함수형 인터페이스로 사용될 수 있다.
예를 들어 CallablePrivilegedAction인터페이스는 인수를 받지 않고 제네릭 형식 T를 반환하는 함수를 정의한다. 따라서 다음 두 할당문 모두 유효한 코드이다.

Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> p = () -> 42;
Comparator<Pencil> c1 = 
	(Pencil p1, Pencil p2) -> p1.getLength().compareTo(p2.getLength());
ToIntBiFunction<Pencil, Pencil> c2 = 
	(Pencil p1, Pencil p2) -> p1.getLength().compareTo(p2.getLength());
BiFunction<Pencil, Pencil, Integer> c3 = 
	(Pencil p1, Pencil p2) -> p1.getLength().compareTo(p2.getLength());

다이아몬드 연산자

다이아몬드 연산자(<>)로 콘텍스트에 따른 제네릭 형식을 추론할 수 있다는 사실을 알고 있을 것이다. 주어진 클래스 인스턴스 표현식을 두 개 이상의 다양한 콘텍스트에 사용할 수 있다. 이 때 인스턴스의 표현식의 형식 인수는 콘텍스트에 의해 추론된다.


특별한 void 호환 규칙

람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호횐된다. 물론 파라미터 리스트도 호환되어야한다.

Predicate<String> p = s -> list.add(s);
Consumer<String> c = s -> list.add(s);

Predicateboolean 값을 기대하고 Consumervoid 값을 기대한다.
list.add(s)boolean 값을 반환하지만 Consumer와도 호환된다.
즉, void를 반환하는 시그니처의 경우 다른 타입도 받을 수 있다.

실습
List<String> list = new ArrayList<>();
Predicate<String> p = s -> list.add(s);
p.test("a");
System.out.println(list.toString());

Consumer<String> c = s -> list.add(s);
c.accept("b");
System.out.println(list.toString());

출력 결과
[a]
[a, b]


명시적 대상 형식

Object o = () -> {System.out.println("dd");};

위 코드는 컴파일할 수 없는 코드이다.
콘텍스트가 Object이지만 Object는 함수형 인터페이스가 아니다.

따라서 () -> void 형식의 함수 디스크립터를 갖는 Runnable로 대상 형식을 바꿔서 문제를 해결할 수 있다.

Runnable r =  () -> {System.out.println("dd");};

또는 람다 표현식을 명시적으로 대상 형식을 제공하는 Runnable로 캐스팅하여 문제를 해결할 수 있다.

Object o = (Runnable) () -> {System.out.println("dd");};

호출
 r.run();
((Runnable) o).run();

이러한 방식은 같은 함수형 디스크립터를 가진 두 함수형 인터페이스를 갖는 메소드를 오버로딩할 때 활용할 수 있다. 어떤 메소드의 시그니처가 사용되어야 하는지를 명시적으로 구분하도록 람다를 캐스트할 수 있다.



형식 추론

자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.
즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.

결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법식에서 이를 생략할 수 있다.

List<Pencil> yellowPencils = filter(inventory, pencil -> COLOR.YELLOW == pencil.getCOLOR();

이전에 사용한 방식대로라면 `(Pencil pencil) -> COLOR.YELLOW == pencil.getCOLOR();` 으로 코드를 작성했다. 그러나 위와 코드처럼 `pencil -> COLOR.YELLOW == pencil.getCOLOR();` 파라미터의 형식을 명시적으로 지정하지 않아도 형식 추론이 가능하므로 유효한 코드이다.

여러 파라미터를 포함하는 람다 표현시에서는 코드 가독성이 더 향상된다.


다음은 Comparator 객체를 만드는 예제를 살펴보겠다.

Comparator<Pencil> pencilComparator1 = 
	(Pencil p1, Pencil p2) -> p1.getLength().compareTo(p2.getLength());
    
Comparator<Pencil> pencilComparator2 = 
	(p1, p2) -> p1.getLength().compareTo(p2.getLength());

상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고 형식을 배제하는 것이 가독성을 향상시킬 때가 있다. 정해진 규칙은 없고 개발자 스스로 결정해야하는 부분이다.



지역 변수 사용

람다 표현식에서는 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 자유 변수를 활용할 수 있다.
이와 같은 동작을 람다 캡쳐링이라고 한다.

int portNumber = 1080;
Runnable r = () -> System.out.println(portNumber);
r.run();		//1080

지역 변수의 제약

  • 명시적으로 final이 선언되어야한다.
  • 실질적으로 final로 선언된 변수처럼 사용되어야한다.
    즉, 한 번만 할당할 수 있는 지역 변수만 캡쳐할 수 있다.

왜 그럴까?
내부적으로 인스턴스 변수와 지역 변수는 태생부터 다르다. 인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 저장된다.

람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하려는 스레드에서는 해당 변수에 접근하려 할 수 있다.

따라서 자바 구현에서는 원래 변수에 접근하는 것이 아니라 자유 지역 변수의 복사본을 제공하고 거기에 접근을 허용한다.
즉, 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생기는 것이다.

또한 지역 변수의 제약 때문에 외부 변수를 변화시키는 일반적인 명령형 프로그래밍 패턴에 제동을 걸 수 있다.



메소드 참조

메소드 참조를 이용하면 기존의 메소드 정의를 재활용해서 람다처럼 전달할 수 있다.
때로는 람다 표현식을 사용하는 것보다 메소드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다.

inventory.sort((p1, p2) -> p1.getLength().compareTo(p2.getLength()));
inventory.sort(Comparator.comparing(Pencil::getLength));	//메소드 참조

메소드 참조는 특정 메소드만을 호출하는 람다의 축약형이라고 생각할 수 있다.
람다가 특정 메소드를 호출할 것을 명령하였을 때 메소드를 어떻게 호출해야하는지에 대한 설명을 참조하는 것보다는 메소드명을 직접 참조하는 것이 편하다.

실제로 메소드 참조를 이용하면 기존 메소드 구현으로 람다 표현식을 만들 수 있다.
이 때 명시적으로 메소드명을 참조함으로써 가독성을 높일 수 있다.

구분자 :: 활용

메소드명 앞에 구분자 (::)을 붙이는 방식으로 메소드 참조를 활용할 수 있다.
예를 들어 Pencil::getLength()는 Pencil 클래스에 정의된 getLength 메소드 참조이다.
실제로 메소드를 호출하는 것이 아니므로 괄호는 사용하지 않는다.

예제
람다메소드 참조 단축 표현
(Pencil p) -> p.getLenth()Pencil::getLenth
() -> Thread.currentThread().dumpStack()Thread.currentThread()::dumpStack
(s, i) -> s.substring(i)String::substring

메소드 참조를 만드는 방법

1. 정적 메소드 참조

예를 들어 Integer의 parseInt 메소드는 Integer::parseInt로 표현할 수 있다.

  • 람다 : (args) -> ClassName.staticMethod(args)
  • 메소드 참조 : ClassName::staicMethod

2. 다양한 형식의 인스턴스 메소드 참조

예를 들어 String의 length 메소드는 String::length로 표현할 수 있다.

  • 람다 : (arg0, rest) -> arg0.instanceMethod(rest)
  • 메소드 참조 : ClassName::instanceMethod

3. 기존 객체의 인스턴스 메소드 참조

예를 들어 Transaction 객체를 할당받은 expensiveTransaction 지역 변수가 있고,
Transaction 객체에는 getValue 메소드가 있다면,
이를 expecnsiveTransaction::getValue라고 표현할 수 있다.
이 유형은 비공개 헬퍼 메소드를 정의한 상황에서 유용하게 활용할 수 있다.

  • 람다 : (args) -> expr.instanceMethod(args)
  • 메소드 참조 : expr::instanceMethod

3. 기존 객체의 인스턴스 메소드 참조 예제

헬퍼 메소드 정의
private boolean isLonger(Pencil p){
    return p.getLength() > 25;
}
호출
longerThan25(inventory, this::isLonger);

List에 포함된 문자열을 대소문자 구분하지 않고 정렬하는 메소드 예제

List의 sort 메소드는 인수로 Comparator를 기대한다.
Comparator(T, T) -> int라는 함수 디스크립터를 갖는다.

대소문자를 구분하지 않고 정렬하는 기능을 제공하는 String클래스의 compareToIgnoreCase 메소드를 이용하여 람다 표현식을 정의할 수 있다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));
str.sort(String::compareToIgnoreCase);

2번 다양한 형식의 인스턴스 메소드 참조 유형의 변환법이 적용되었다.
컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메소드 참조가 주어진 함수형 인터페이스와 호환하는 지 확인한다.

즉, 메소드 참조는 콘텍스트의 형식과 일치해야한다.

생성자 참조

ClassName::new처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다.

인수가 없는 생성자 예제

Supplier() -> T 함수 디스크립터를 이용해보자.
() -> Pencil와 같은 시그니처를 갖는 생성자가 있다고 가정하자.

Supplier<Pencil> s = () -> new Pencil();
Supplier<Pencil> s = Pencil::new;
Pencil p = s.get();		// 객체 생성

인수가 1개 있는 생성자 예제

FunctionT -> R 함수 디스크립터를 이용해보자.
Pencil(Integer length)라는 시그니처를 갖는 생성자는 Function 인터페이스의 시그니처와 같다.

Function<Integer, Pencil> f = (i) -> new Pencil(i);
Function<Integer, Pencil> f = Pencil::new;
f.apply(60);			// 객체 생성
활용) 다양한 길이의 연필 리스트 생성
map 메소드 생성
public static List<Pencil> map(List<Integer> list, Function<Integer, Pencil> f){
    List<Pencil> pencils = new ArrayList<>();
    for(Integer i : list){
        pencils.add(f.apply(i));
    }
    return pencils;
}
호출
List<Integer> lengths = Arrays.asList(10, 20, 30);
Function<Integer, Pencil> f = Pencil::new;

List<Pencil> pencils = map(lengths, f);

인수가 2개 있는 생성자 예제

BiFunction(T, U) -> R 함수 디스크립터를 이용해보자.
Pencil(COLOR color, Integer length)라는 시그니처를 갖는 생성자는 BiFunction 인터페이스의 시그니처와 같다.

BiFunction<COLOR, Integer, Pencil> f = (color, i) -> new Pencil(color, i);
BiFunction<COLOR, Integer, Pencil> f = Pencil::new;
Pencil p = f.apply(COLOR.BLACK, 50);	// 객체 생성 

인스턴스화하지 않고도 생성자에 접근할 수 있는 기능을 다양한 상황에 응용할 수 있다.
예를 들어 Map으로 생성자와 문자열 값을 관련시킬 수 있다. 그리고 String와 Integer가 주어졌을 때 다양한 길이를 갖는 여러 종류의 연필을 만드는 giveMePencil라는 메소드를 만들 수 있다.

static Map<String, Function<Integer, Pencil>> map = new HashMap<>();
static {
    map.put("pencil1", Pencil::new);
    map.put("pencil2", Pencil::new);
}
메소드 생성
public static Pencil giveMePencil(String pencil, Integer length){
    return map.get(pencil).apply(length);
}
호출
System.out.println(giveMePencil("pencil1", 30));	//Pencil@6acbcfc0

인수가 3개 이상인 생성자 예제

생성자 참조 문법을 사용하려면 생성자 참조와 일치하는 시그니처를 갖는 함수형 인터페이스가 필요하다.
현재 이런 시그니처를 갖는 함수형 인터페이스는 제공되지 않으므로 우리가 직접 만들어야한다.

TriPencil 인터페이스 생성
public interface TriPencil<T, U, V, R>{
    R apply(T t, U u, V v);
}
호출
TriPencil<COLOR, Integer, String, Pencil> pencil = Pencil::new;


람다, 메소드 참조 활용하기

위에 작성한 연필 길이를 정렬하는 문제로 돌아가 이 문제를 조금 더 간결하고 세련되게 해결하는 방법을 실습하겠다.
지금까지 배운 동작 파라미터화, 익명 클래스, 람다 표현식, 메소드 참조 등을 총동원한다.

기존 sort 활용 코드
List<Pencil> pencils = Arrays.asList(new Pencil(COLOR.YELLOW, 50),
                                     new Pencil(COLOR.GREEN, 20),
                                     new Pencil(COLOR.YELLOW, 30));
pencils.sort(Comparator.comparing(Pencil::getLength));

1단계: 코드 전달

연필 길이를 정렬하는 정렬 메소드는 List API의 sort 메소드를 사용하므로 직접 구현할 필요가 없었다.
그렇다면 sort 메소드는 어떻게 정렬 전략을 전달할 수 있을까?
sort 메소드의 시그니처void sort(Comparator<? super E> c) 이다.

public class PencilComparator implements Comparator<Pencil>{
    public int compare(Pencil p1, Pencil p2){
        return p1.getLength().compareTo(p2.getLength());
    }
}

pencils.sort(new PencilComparator());

List의 sort 메소드Comparator 객체를 인수로 받아 두 연필을 비교한다. 객체 안에 동작을 포함시키는 방식으로 다양한 전략을 전달할 수 있다. 이제 'sort의 동작은 파라미터화되었다'라고 말할 수 있다.


2단계: 익명 클래스 사용

한 번만 사용할 코드는 1단계보다는 익명 클래스를 사용하는 것이 좋다.

pencils.sort(new Comparator<Pencil>() {
    @Override
    public int compare(Pencil o1, Pencil o2) {
        return o1.getLength().compareTo(o2.getLength());
    }
});

3단계: 람다 표현식 사용

Comparator의 함수 디스크립터는 (T, T) -> int이다.
우리는 연필을 이용할 것이므로 더 정확히는 (Pencil, Pencil) -> int로 표현할 수 있다.

pencils.sort((p1, p2) -> p1.getLength().compareTo(p2.getLength()));

이 코드의 가독성을 더 높일 수는 없을까?
ComparatorComparable키를 추출해서 Comparator 객체로 만드는 Function 함수를 인수로 받는 정적 메소드 comparing을 포함한다.

다음처럼 comparing 메소드를 활용할 수 있다. 람다 표현식은 연필을 비교하는 데 사용할 키를 어떻게 추출할 것인지 정하는 한 개의 인수만 포함한다.

Comparator<Pencil> c = Comparator.comparing((Pencil p) -> p.getLength());

이를 활용한 코드이다.

pencils.sort(Comparator.comparing(pencil -> pencil.getLength()));

4단계: 메소드 참조 활용

메소드 참조를 활용하여 3단계 코드를 조금 더 간소화할 수 있다.

pencils.sort(Comparator.comparing(Pencil::getLength));

코드가 간결해지고 의미도 명확해졌다.
즉, 코드 자체로 'Pencil을 length별로 비교해서 pencils를 sort하라.'라는 의미를 전달할 수 있다.


람다 표현식을 조합할 수 있는 유용한 메소드

자바 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메소드를 포함한다.
예를 들어 Comparator, Function, Predicate 같은 함수형 인터페이스는 람다 표현식을 조합할 수 있도록 유틸리티 메소드를 제공한다.

즉, 간단한 여러 개의 람다 표현식을 조합하여 복잡한 람다 표현식을 만들 수 있다.

예를 들어 두 개의 Predicate를 조합하여 or 연산을 수행하는 커다란 Predicate를 만들 수 있다.

도대체 함수형 인터페이스에서는 어떤 메소드를 제공하기에 이런 일이 가능한걸까?

여기서 등장하는 것이 바로 디폴트 메소드이다.
추상 메소드가 아니므로 함수형 인터페이스 정의를 벗어나지 않는다.


Comparator 조합

연필을 정렬하는 Comparator가 있다.

Comparator<Pencil> c = Comparator.comparing(Pencil::getLength);

역정렬

연필의 길이를 내림차순으로 정렬하고 싶다면 어떻게 해야할까?
새로운 Comparator 인스턴스를 만들 필요가 없다. 인터페이스 자체에서 주어진 비교자의 순서를 바꿔주는 reverse라는 디폴트 메소드를 제공하기 때문이다.

List<Pencil> pencils = Arrays.asList(new Pencil(COLOR.YELLOW, 50),
                                     new Pencil(COLOR.GREEN, 20),
                                     new Pencil(COLOR.YELLOW, 30));

Comparator<Pencil> c = Comparator.comparing(Pencil::getLength);
System.out.println(pencils);
pencils.sort(c);
System.out.println(pencils);
pencils.sort(c.reversed());
System.out.println(pencils);

출력 결과
[Pencil@5b480cf9, Pencil@6f496d9f, Pencil@723279cf]
[Pencil@6f496d9f, Pencil@723279cf, Pencil@5b480cf9]
[Pencil@5b480cf9, Pencil@723279cf, Pencil@6f496d9f]


Comparator 연결

길이가 같은 연필이 존재한다면 어떻게 해야할까? 정렬된 리스트에서 어떤 연필을 먼저 나열해야할까?
이럴 땐 결과를 더 다듬을 수 있는 두 번째 Comparator를 만들 수 있다.
thenComparing 메소드를 이용하여 두 번째 비교자를 생성한다.
이 메소드는 함수를 인수로 받아 첫 번째 비교자를 이용해서 두 객체가 같다고 판단되면 두 번째 비교자에게 객체를 전달한다.

Comparator<Pencil> c = 
	Comparator.comparing(Pencil::getLength)
    .reversed()
    .thenComparing(Pencil::getColor);

Predicate 조합

Predicate 인터페이스는 복잡한 Predicate를 만들 수 있도록 negate, and, or 세 가지 메소드를 제공한다.

negate 메소드

List<Pencil> pencils = Arrays.asList(new Pencil(COLOR.YELLOW, 50),
                                     new Pencil(COLOR.YELLOW, 30),
                                     new Pencil(COLOR.RED, 30));

Predicate<Pencil> yellowPencil = (p) -> p.getColor() == COLOR.YELLOW;
Predicate<Pencil> notYellowPencil = yellowPencil.negate();

List<Pencil> list = PencilFiltering.filter(pencils, notYellowPencil);

negate 메소드는 특정 Predicate반전시킬 때 사용할 수 있다.

and 메소드

Predicate<Pencil> longerThen40 = (p) -> p.getLength() > 40;
List<Pencil> list = 
	PencilFiltering.filter(pencils, yellowPencil.and(longerThen40));

or 메소드

List<Pencil> list = PencilFiltering.filter(pencils, yellowPencil.or(longerThen40));

Function 조합

Function 인터페이스는 Function 인스턴스를 반환하는 andThen, compose 두 가지 디폴트 메소드를 제공한다.

andThen

andThen 메소드는 주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수를 반환한다.
예를 들어 숫자를 증가(x -> x + 1)시키는 f라는 함수가 있고, 숫자에 2를 곱하는 g라는 함수가 있다고 가정하자.
이제 다음처럼 f와 g를 조립해서 숫자를 증가시킨 뒤 결과에 2를 곱하는 h라는 함수를 만들 수 있다.
수학 수식으로 표현한다면 g(f(x))이다.

Function<Integer, Integer> f = (x) -> x + 1;
Function<Integer, Integer> g = (x) -> x * 2;

Function<Integer, Integer> h = (f).andThen(g);
h.apply(2);		//6 

compose

compose 메소드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다.
즉, 위의 코드에서 andThen 대신 compose를 사용한다면 수학 수식으로 f(g(x))이다.

Function<Integer, Integer> f = (x) -> x + 1;
Function<Integer, Integer> g = (x) -> x * 2;

Function<Integer, Integer> h = (f).compose(g);
h.apply(2);		//5 


이 글은 모던 인 자바 액션 책을 실습하며 참고하여 작성한 글입니다.

0개의 댓글