잡동사니

자바에서 람다식을 사용하는 방법 본문

IT/Java

자바에서 람다식을 사용하는 방법

yeTi 2021. 3. 29. 13:47

안녕하세요. yeTi입니다.
오늘은 자바에서 람다식을 사용하는 방법에 대해 공유하고자 합니다.

제가 처음 Oracle Java DocumentationLambda Expressions을 읽은 이유는 단순히 자바에서 사용하는 람다의 표현식에 대해서 알고자 함이었습니다.
하지만 documentation 에서 제공하는 시나리오를 읽으면서 단순히 function 을 파라메터로 전달하는 형식에 그치는 것이 아니라
function 을 파라메터로 전달하는 형식에 의해 얻을 수 있는 인터페이스의 변화나 확장성에 대해 인지할 수 있는 시간이었기 때문에 해당 내용을 공유하게 됐습니다.

개요

자바에서는 interface (하나의 추상 함수를 가지는)anonymous classes (익명 클래스)를 활용하여 로직을 함수 사용시에 정의할 수 있습니다.

하지만 이러한 방식은 로직 이외에 부가적인 코드가 추가될 뿐만아니라 구현을 위한 구조 또한 불명확하다는 문제점이 있는데요. 이러한 문제점을 lambda expresion으로 해결할 수 있습니다.

아래 구현 시나리로를 예시로 보면서 앞에서 언급한 내용들을 확인해보겠습니다.

활용

전체 샘플 코드 를 확인할 수 있습니다.

사람을 검색하는 시나리오를 가정해보겠습니다.

사람을 표현하기 위해 Person클래스를 정의합니다.

public class Person {

    public enum Sex {
        MALE, FEMALE
    }

    String name;
    LocalDate birthday;
    Sex gender;
    String emailAddress;

    public int getAge() {
        // ...
    }

    public void printPerson() {
        // ...
    }
}

Approach 1: 하나의 검색조건으로 검색하는 함수를 생성합니다.

일반적으로 아래 예와같이 간단한 인터페이스를 생각할 수 있을 것입니다.

public static void printPersonsOlderThan(List<Person> roster, int age) {
    for (Person p : roster) {
        if (p.getAge() >= age) {
            p.printPerson();
        }
    }
}

하지만 age보다 어린 사람을 검색하고 싶은 경우가 생기면 해결방법은 두가지가 될 것입니다.
하나는, 기존 함수의 특성을 유지하는 방향으로 새로운 함수를 추가할 수 있습니다. 하지만 이러한 방식은 동일한 속성을 검색하지만 검색 조건에 따라 함수가 나뉘는 문제가 있습니다.
다른 하나는, 기존 함수의 특성을 변경하는 방향인데, 이는 기존에 함수를 사용하고 있던 곳들을 점검 및 수정하는 이슈가 있습니다.

Approach 2: 좀더 Generalized 하게 검색 함수를 정의해 보겠습니다.

public static void printPersonsWithinAgeRange(
    List<Person> roster, int low, int high) {
    for (Person p : roster) {
        if (low <= p.getAge() && p.getAge() < high) {
            p.printPerson();
        }
    }
}

age의 검색 특성을 변경했지만, 나이보다 적은, 나이보다 많은과 같은 특성은 포함할 수 없고, 다른 속성과 혼합하여 검색하고 싶은 경우에 사용할 수 없습니다.
이제부터 interface를 활용하여 검색 로직을 분리해보겠습니다.

Approach 3: 검색 조건 로직을 분리합니다.

인터페이스를 정의합니다.

interface CheckPerson {
    boolean test(Person p);
}

인터페이스의 구현체를 정의합니다.

class CheckPersonEligibleForSelectiveService implements CheckPerson {
    public boolean test(Person p) {
        return p.gender == Person.Sex.MALE &&
            p.getAge() >= 18 &&
            p.getAge() <= 25;
    }
}

인터페이스를 전달받아 수행하도록 검색조건을 구현합니다.
이러한 구조는 검색조건에 대할 설정의 역할을 함수 외부에서 수행하도록 하여, 검색 조건 변경이 printPersons 인터페이스에 영향을 주지 않도록 할 수 있습니다.

public static void printPersons(
    List<Person> roster, CheckPerson tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}

인터페이스의 사용 관점에서 보면 다음과 같습니다.

printPersons(
    roster, new CheckPersonEligibleForSelectiveService());

검색 조건의 변경이나 Person 클래스의 변경에도 함수의 인터페이스를 변하지 않도록 수정이 됐습니다.
하지만 검색 조건의 구현을 위해 interface와 implements를 생성해야하는 불편함이 남아있습니다.

Approach 4: 익명 클래스를 사용해 보겠습니다.

printPersons(
    roster,
    new CheckPerson(){

        @Override
        public boolean test(Person p) {
            return p.getGender() == Person.Sex.MALE
                && p.getAge() >= 18
                && p.getAge() <= 25;
        }

    }
);

익명 클래스를 활용함으로써 implements를 생성하지 않고 로직을 전달할 수 있게 됐습니다.

여기까지 인터페이스와 익명 클래스를 활용하여 검색 조건의 역할을 다른곳으로 빼서 검색 함수를 보다 general하게 정의하는 과정을 확인했습니다.

하지만 익명 클래스를 사용하면서 생기는 불필요한 코드가 여전히 남아있습니다. 이는 람다 표현식을 사용하여 해결해보도록 하겠습니다.

Approach 5: 람다 표현식으로 변경해 봅니다.

printPersons(
    roster,
    (Person p) -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25
);

익명 함수를 사용함으로써 생기는 불필요한 코드가 확연히 줄어든 것을 확인할 수 있습니다.

여기서 확인할 수 있는 점은, CheckPerson interface는 functional interface 라는 것입니다.
functional interface 는 하나의 abstract method 를 가지는 interface를 지칭하는데요.
functional interface가 하나의 abstract method 를 가짐으로써 interface의 이름과 abstract method 이름을 생략 가능한 구조가 됩니다.

Approach 6: 자바에서 제공하는 Standard Functional Interfaces를 활용해 보겠습니다.

자바에서 람다 표현식을 사용하기 위해서는 인터페이스가 필요합니다. 매번 필요한 인터페이스를 정의해서 사용한다는 것은 매우 불편한데요.
이에 JDK는 몇몇 standard functional interfacesjava.util.function 패키지에 제공합니다.

그 중 데이터를 전달받아 boolean 을 반환하는 Predicate<T>를 사용하여 CheckPerson 인터페이스를 대체하면 아래와 같이 변경할 수 있습니다.

public static void printPersonsWithPredicate(
    List<Person> roster, Predicate<Person> tester) {
    for (Person p : roster) {
        if (tester.test(p)) {
            p.printPerson();
        }
    }
}
printPersonsWithPredicate(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25
);

interface 의 정의나 implements 를 정의하지 않고 람다 표현식을 활용하여 검색 조건을 function 으로 전달할 수 있도록 변경했습니다.

Approach 7: 람다 표현식을 확장해서 사용해 보겠습니다.

printPerson() 의 로직도 함수의 사용시점에 정의하고 싶다면 lambda expressions 을 활용할 수 있습니다.
다만, 새로운 유형의 functional interfaces 가 필요합니다.(하나의 argument 를 받고 void 를 반환하는 형태)
JDK 에서 제공하는 Consumer<T> interface 에 accept() 함수가 동일한 형식을 가지고 있어 이를 활용할 수 있습니다.

public static void processPersons(
    List<Person> roster,
    Predicate<Person> tester,
    Consumer<Person> block) {
        for (Person p : roster) {
            if (tester.test(p)) {
                block.accept(p);
            }
        }
}

사용자는 함수 사용시점에 검색 조건에 해당하는 경우 동작에 대해 정의할 수 있습니다.

processPersons(
     roster,
     p -> p.getGender() == Person.Sex.MALE
         && p.getAge() >= 18
         && p.getAge() <= 25,
     p -> p.printPerson()
);

한번 더 확장을 해보겠습니다.
하나의 argument 를 받고 value 를 반환하는 형태를 사용하고 싶으면, Function<T,R> interface 의 R apply(T t) 을 사용하면 됩니다.

public static void processPersonsWithFunction(
    List<Person> roster,
    Predicate<Person> tester,
    Function<Person, String> mapper,
    Consumer<String> block) {
    for (Person p : roster) {
        if (tester.test(p)) {
            String data = mapper.apply(p);
            block.accept(data);
        }
    }
}

사용자는 함수 사용시점에 검색 조건에 해당하는 경우 동작에 대해 정의할 수 있습니다.

processPersonsWithFunction(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25,
    p -> p.getEmailAddress(),
    email -> System.out.println(email)
);

Approach 8: 좀더 Generics 하게 확장해 보겠습니다.

PersonString을 각각 XY로 치환하여 타입의 종속성을 제거해 봅니다.

public static <X, Y> void processElements(
    Iterable<X> source,
    Predicate<X> tester,
    Function <X, Y> mapper,
    Consumer<Y> block) {
    for (X p : source) {
        if (tester.test(p)) {
            Y data = mapper.apply(p);
            block.accept(data);
        }
    }
}
processElements(
    roster,
    p -> p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25,
    p -> p.getEmailAddress(),
    email -> System.out.println(email)
);

Approach 9: Aggregate Operations 을 사용하여 Lambda Expressions 을 파라메터로 받아보겠습니다.

지금까지 검색을 위해 검색 함수를 정의하고 lambda expresion을 사용했습니다.
이는 자바에서 제공하는 Aggregate Operations을 활용하면 좀더 심플하게 변경할 수 있습니다.

roster
    .stream()
    .filter(
        p -> p.getGender() == Person.Sex.MALE
            && p.getAge() >= 18
            && p.getAge() <= 25)
    .map(p -> p.getEmailAddress())
    .forEach(email -> System.out.println(email));

문법

p -> p.getGender() == Person.Sex.MALE 
    && p.getAge() >= 18
    && p.getAge() <= 25
p -> {
    return p.getGender() == Person.Sex.MALE
        && p.getAge() >= 18
        && p.getAge() <= 25;
}

결론

람다식이란 함수형 언어의 특징 중 하나로, 익명 함수를 표현하는 방식입니다.
자바에서는 함수의 인자로 함수를 전달하는 형태로 사용하기 위하여 interface익명 함수의 조합을 사용하는데요.

JDK에서 제공하는 standard functional interfaces람다식을 사용하여 함수의 인자로 함수를 전달하는 형태로 사용할 수 있습니다.

Comments