Java/STUDY HALLE

[Java] 람다

무토(MUTO) 2021. 3. 5. 23:11

0. 학습 목표

  • 람다식 사용법
  • 함수형 인터페이스
  • variable capture
  • 메소드, 생성자 레퍼런스

0-1. 왜 람다인가?

사과는 무게, 색의 데이터를 가진다. 그리고 여기에 다양한 사과들의 리스트가 있다고 가정해보자.

Apple.java

public class Apple {
    private Color color;
    private int weight;

    public Apple(Color color, int weight) {
        this.color = color;
        this.weight = weight;
    }

    public Color getColor() {
        return color;
    }

    public void setColor(Color color) {
        this.color = color;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }
}

Main.java

public class Main {

    public static void main(String[] args) throws IOException {
        List<Apple> apples = new ArrayList<>(List.of(
            new Apple(Color.RED, 100),
            new Apple(Color.RED, 130),
            new Apple(Color.RED, 180),
            new Apple(Color.GREEN, 190),
            new Apple(Color.GREEN, 150),
            new Apple(Color.GREEN, 140),
            new Apple(Color.RED, 110),
            new Apple(Color.YELLOW, 120),
            new Apple(Color.YELLOW, 160),
            new Apple(Color.RED, 230)
        ));
    }
}

해당 Apple 리스트에서 Color 가 GREEN인 사과를 걸러내고 싶다면 어떻게 해야할까? 다음과 같이 구현해야 할 것이다

private static List<Apple> filterGreenApple(List<Apple> apples) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : apples) {
        if (apple.getColor().equals(Color.GREEN)) {
            result.add(apple);
        }
    }
    return result;
}

해당 메소드는 녹색인 사과만 골라내고 있는데 조금 더 포괄적으로 색상을 선택해서 골라낼 수 있게 바꿔보자.

private static List<Apple> filterAppleByColor(List<Apple> apples, Color color) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : apples) {
        if (apple.getColor().equals(color)) {
            result.add(apple);
        }
    }
    return result;
}

색상에 추가적으로 무게로도 분류를 진행하고싶다. 한번 구현을 해보자.

private static List<Apple> filterAppleByColor(List<Apple> apples, Color color, int weight, boolean flag) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : apples) {
        if ((flag && apple.getColor().equals(color)) || (!flag && apple.getWeight() > weight)) {
            result.add(apple);
        }
    }
    return result;
}

무엇을 하고싶은 코드인지 의도가 전혀 드러나지 않는다. 추가적으로 요구사항이 늘어난다면 이것을 어떻게 처리해야할지 감도 잡히지 않는다.

그렇다면 지금까지 값을 파라미터로 전달한것과는 다르게 동작을 파라미터로 전달을 해보자.

선택 조건을 결정하는 인터페이스를 파라미터로 넘기는 것이다.

public class Main {

    public static void main(String[] args) throws IOException {
        List<Apple> apples = new ArrayList<>(List.of(
            new Apple(Color.RED, 100),
            new Apple(Color.RED, 130),
            new Apple(Color.RED, 180),
            new Apple(Color.GREEN, 190),
            new Apple(Color.GREEN, 150),
            new Apple(Color.GREEN, 140),
            new Apple(Color.RED, 110),
            new Apple(Color.YELLOW, 120),
            new Apple(Color.YELLOW, 160),
            new Apple(Color.RED, 230)
        ));

        List<Apple> greenApples = filterApples(apples,new AppleGreenColorPredicate());

    }

    private static List<Apple> filterApples(List<Apple> apples, ApplePredicate predicate) {
        List<Apple> result = new ArrayList<>();
        for (Apple apple : apples) {
            if (predicate.test(apple)) {
                result.add(apple);
            }
        }
        return result;
    }
}

interface ApplePredicate {
    boolean test(Apple apple);
}

class AppleGreenColorPredicate implements ApplePredicate{

    @Override
    public boolean test(Apple apple) {
        return apple.getColor().equals(Color.GREEN);
    }
}

class AppleHeavyWeightPredicate implements ApplePredicate{

    @Override
    public boolean test(Apple apple) {
        return apple.getWeight() > 100;
    }
}

이제 우리가 전달한 ApplePredicate 객체에 의해서 filterApples의 동작이 결정된다. 추가적으로 필터링하고 싶으면 얼마든지 현재까지의 코드에 영향을 주지 않고 추가할 수 있다.

그러나 코드가 유연해진것은 좋으나, 만약 이 메소드를 한 번 씩 밖에 사용하지 않는 경우에는 굳이 클래스를 만들어서 이렇게 까지 해야하나 싶은 느낌이 있을것이다.

그래서 자바에서는 다음과 같은 방식을 사용하여 메소드의 동작을 파라미터화 한다.

public class Main {

    public static void main(String[] args) throws IOException {
        List<Apple> apples = new ArrayList<>(List.of(
            new Apple(Color.RED, 100),
            new Apple(Color.RED, 130),
            new Apple(Color.RED, 180),
            new Apple(Color.GREEN, 190),
            new Apple(Color.GREEN, 150),
            new Apple(Color.GREEN, 140),
            new Apple(Color.RED, 110),
            new Apple(Color.YELLOW, 120),
            new Apple(Color.YELLOW, 160),
            new Apple(Color.RED, 230)
        ));

        List<Apple> greenApples = filterApples(apples, new ApplePredicate() {
            @Override
            public boolean test(Apple apple) {
                return apple.getColor().equals(Color.GREEN);
            }
        });

    }

    private static List<Apple> filterApples(List<Apple> apples, ApplePredicate predicate) {
        List<Apple> result = new ArrayList<>();
        for (Apple apple : apples) {
            if (predicate.test(apple)) {
                result.add(apple);
            }
        }
        return result;
    }
}

다음과 같은 방식을 사용하면 한번만 사용 할 predicate이라면 굳이 클래스를 하나 추가적으로 만들어서 구현을 해줄 필요가 없다.

그러나 파라미터가 너무 길어져서 알아보기 힘들다는 단점이 있다.

그럴때 람다식을 활용한다.

List<Apple> greenApples = filterApples(
    apples,
    apple -> apple.getColor().equals(Color.GREEN)
);

맨 위의 코드와 비교해서 굉장히 간단하고 편리한 코드가 되었다. 다음과 같이 람다를 사용하면 훨씬 짧은 양의 코드를 통해서 동작 파라미터화를 진행할 수 있다.

1. 람다식 사용법

람다식은 다음과 같은 방법으로 사용할 수 있다.


List<Apple> greenApples = filterApples(
    apples,
    apple -> apple.getColor().equals(Color.GREEN)
);

List<Apple> greenApples = filterApples(
    apples,
    apple -> {
        return apple.getColor().equals(Color.GREEN);
    }
);

List<Apple> greenApples = filterApples(
    apples,
    apple -> {
        if (apple.getColor().equals(Color.GREEN)) {
            return true;
        }
        return false;
    }
);

그러나 솔직히 말하면 나는 자바의 각각의 메소드에 어떤 파라미터가 있고 몇번째 파라미터가 어떤 값인지 잘 모른다.

그래서 그냥 해당 인터페이스를 불러오고 IDE의 람다 표현식 변환을 사용하여 람다로 변환한다.

또, 여기서 우리 서터디의 자랑인 바이트코드를 살펴보지 않을 수 없다.

자세히 살펴보니 밑줄 친 빨간 부분의 차이가 있었다. 위의 코드는 익명 내부 클래스를 사용하여 동작 파라미터화를 진행한 코드이고,

아래의 코드는 람다를 활용하여 동작 파라미터화를 진행한 코드이다. 저 두가지의 차이점은 무엇일까?

https://tourspace.tistory.com/12?category=788398

자세한 내용은 위 블로그 주소에 잘 담겨있으니 확인을 해보도록 하자.

요약을 하자면, 컴파일 타임에는 어떤 방법으로 객체를 생성할지 정하지 않고 런타임에 어떤 방법으로 객체를 생성할 지 정하는 것이다.

동시에 싱글턴 패턴과 비슷하게 하나의 함수형 인터페이스만을 생성하고 계속해서 재사용하는 성질도 가지고 있다.

이를통해 약간의 성능상의 이점과, 추후 업데이트 될 최적화 하는 로직을 분리하여 관리할 수 있다는 장점이 있다.

2. 함수형 인터페이스

함수형 인터페이스는 하나의 추상메서드를 지정하는 인터페이스이다.
위에 이미 사용했던 ApplePredicate가 바로 함수형 인터페이스라고 말할 수 있다.

이 함수형 인터페이스로 무엇을 할 수 있을까?

람다 표현식으로 함수형 인터페이스의 추상메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.

왜 그러면 함수형 인터페이스를 인수로 받는 메서드에만 람다 표현식을 사용할 수 있을까? 라는 의문이 생길 수도 있는데 언어 설계자들이 언어를 더 복잡하게 만들지 않는 방향으로 개발을 진행하여 현재 하나의 추상메서드를 갖는 인터페이스만 람다에 사용할 수 있도록 설계되었다.

! @FunctionalInterface

해당 애노테이션을 선언하면 해당 인터페이스가 함수형 인터페이스가 아니면 컴파일 에러를 발생시킨다. 우리의 오류를 줄여줄 수 있는 고마운 애노테이션이다. 함수형 인터페이스를 사용할 때에는 반드시 이 애노테이션을 추가하여 넣도록 하자.

3. Variable Capture

람다 표현식 외부의 변수를 람다 표현식 내부에서 사용할 수 있도록 하는 동작이다.

코드로 이해를 해보자

int weight = 150;
List<Apple> HeavyApples = filterApples(
    apples,
    apple -> apple.getWeight() > weight  //컴파일 에러
);
weight = 40;

다음과 같이 람다 외부에 있는 weight라는 변수를 람다 안에서 사용하려고 한다면 명시적으로 final을 선언해주어야한다.

아니면 사실상 코드상에서 final과 같이 아무런 외부의 요인이 없어야 한다.

왜 이렇게 코드를 작성 해야 하는 것일까?

바로 지역변수는 jvm의 스택메모리에 값이 올라가기 때문이다. 그렇게되면 해당 값을 직접 접근하지 않고 자신의 스택에 복사한다.

람다는 별도의 쓰레드에서 실행이 가능하므로 외부요인이 값을 바꾸면 immutable하지 않게 되기 때문에 final을 명시적으로 선언해주거나,

혹은 사실상 새로운 병수의 할당이 없어야 하는것이다.

int weight = 150;
List<Apple> HeavyApples = filterApples(
    apples,
    apple -> apple.getWeight() > weight  //정상적인 코드
);

final

final int weight = 150;
List<Apple> HeavyApples = filterApples(
    apples,
    apple -> apple.getWeight() > weight  //정상적인 코드
);

4. 메소드 참조, 생성자 참조

기존의 메소드 정의를 재활용해서 람다처럼 전달할 수 있는 문법이다.

일단 코드부터 보자.



내가 일반적으로 메소드 참조를 사용할때 사용하는 방법이다.

IDE의 도움을 받으면 메소드 참조, 람다로의 전환이 아주 편리하다.

이러한 코드의 방식이 왜 중요할까? 직관적으로 판단해보자

가독성이 너무 좋아진다.

그렇다면 메소드 참조는 어떻게 만들까?

다음과 같은 3가지의 방법이 있다.

  1. 정적 메소드 참조.

String[] strings = new String[]{"1","2","3"};
int[] ints = Arrays.stream(strings).map(Integer::parseInt).mapToInt(v -> v).toArray();
  1. 다양한 형식의 인스턴스 메소드 참조

String[] strings = new String[]{"1","2","3"};
int[] ints = Arrays.stream(strings).map(String::length).mapToInt(v->v).toArray();
  1. 기존 객체의 인스턴스 메소드 참조.

apples.stream().map(Apple::getColor).toArray();

그렇다면 생성자 참조는 무엇일까?

이것도 코드부터 보도록 하자.

Integer[] integers = apples.stream().map(Apple::getWeight).toArray(Integer[]::new);

맨 뒤에 있는 new를 사용한 부분이 바로 생성자 참조이다.

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

인스턴스화 하지 않고도 생성자에 접근할 수 있는 기능이다.

'Java > STUDY HALLE' 카테고리의 다른 글

[Java] enum  (0) 2021.01.30
[Java] 쓰레드 Thread  (0) 2021.01.20
[Java] 예외처리  (0) 2021.01.15
[Java] 인터페이스  (0) 2021.01.08
[Java] 패키지  (0) 2020.12.29