15주차 과제 : 람다식

Lee·2021년 3월 5일
0
post-thumbnail

람다식

자바에서 제공하는 람다식에 대해 학습해보자 📖

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

람다식(Lambda Expression)?

  • 함수(메소드)를 간단한 '식(expression)'으로 표현하는 방법
int max(int a, int b) { // 간단한 식으로 표현하기 전
  return a > b ? a : b;
}
(a, b) -> a > b? a : b // 간단한 식으로 표현한 후
  • 익명 함수(이름이 없는 함수, annonymous function)
int max(int a, int b) { // 간단한 식으로 표현하기 전
  return a > b ? a : b;
}
(int a, int b) -> { // 메소드의 반환타입, 메소드의 이름을 제거 후 ->을 통해 메소드의 실행부분을 나타낸다
  return a > b ? a : b;
}
  • 함수의 메소드의 차이
    • 근본적으로 동일, 함수는 일반적 용어, 메소드는 객체지향개념 용어
    • 함수는 클래스에 독립적, 메소드는 클래스에 종속적

람다식 작성하기

  1. 메소드의 이름과 반환타입을 제거하고 '->'를 블록{} 앞에 추가한다.
int max(int a, int b) { 
  return a > b ? a : b;
}
(int a, int b) -> { 
  return a > b ? a : b;
}

이렇게 까지만 바꿔놔도 람다식으로 충분히 쓸 수 있다. 하지만 람다식은 메소드를 간단히 표현하는 식이라고 위에서 언급했다. 그에 맞게 더 간단하게 표현할 수 있는 규칙들이 더 있다.

  1. 반환값이 있는 경우, 식이나 값만 적고 return문 생략 가능 (끝에 ';' 생략)
(int a, int b) -> {
  return a > b ? a : b;
}
(int a, int b) -> a > b ? a : b
  1. 매개변수의 타입이 추론 가능하면 생략가능(대부분의 경우 생략가능)
(int a, int b) -> a > b ? a : b
(a, b) -> a > b ? a : b

람다식 작성하면서 주의할 사항

  1. 매개변수가 하나인 경우, 괄호() 생략가능(타입이 없을 때만)
	(a) -> a * a
(int a) -> a * a
	a -> a * a // 가능
int a -> a * a // 대부분 타입을 생략하지만 그렇지 않을 경우 에러가 발생한다.
  1. 블록 안의 문장이 하나뿐 일 때, 괄호{}생략가능(끝에 ';' 안 붙임)
(int i) -> {
  System.out.println(i);
}
(int i) -> System.out.println(i)

단, 하나뿐인 문장이 return문이면 괄호{} 생략불가

(int a, int b) -> {return a > b ? a : b;} // 가능
(int a, int b) -> return a > b ? a : b // 에러

람다식의 예

메소드람다식
int max(int a, int b) {
return a > b ? a : b
}
(a, b) -> a > b ? a :b
int printVar(String name, int i) {
System.out.println(name + "=" + i);
}
(name, i) -> System.out.println(name + "=" + i)
int square(int x) {
return x * x;
}
x -> x * x
int roll() {
return (int) (Math.random() * 6);
}
() -> (int) (Math.random() * 6)

익명객체

  • 자바에선 메소드만 따로 존재할 수 없기 때문에 자바에서만큼 람다식은 익명 함수가 아니라 익명 객체로 존재한다.
  • 또한 람다식을 호출하기 위해선 이름이 필요하고, 이름이 필요할려면 타입도 필요하게 된다.
  • 그렇다면 어떤 클래스에 포함되어야 객체화 시키고, 변수명을 지을 수 있는데...? 일단 한번 Object로 사용해보자
(a, b) -> a > b ? a : b // 이와 같은 람다식은
new Object() { // 이러한 익명 객체로 나타낼 수 있다.
  int max(int a, int b) {
    return a > b ? a : b;
  }
}

그러면 이 익명 객체를 만약 사용한다고 하면 변수가 필요하고, 그 변수를 만들기 위해선 변수타입도 필요한데 그럼 어떻게 해야할까?

Object obj = new Object() {
    int max(int a, int b) {
    return a > b ? a : b;
  }
}

바로 위에서 만들었던 익명 클래스를 객체화 시킨다음에 사용한다고 했을 때

타입 obj = (a, b) -> a > b ? a : b // 어떤 타입으로 맞춰야할까..?
int value = obj.max(3, 5); // 에러 Object 클래스에 max()가 없다.

그럼 이런 상태면 위와 같이 만든 익명 객체를 사용할 수 없는걸까? 이때 필요한 것이 함수형 인터페이스이다.

함수형 인터페이스

  • 단 하나의 추상 메소드만 선언된 인터페이스
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;
      }
};

위와 같이 만들었으면 이제 익명 객체의 max() 메소드를 호출할 수 있다. 어떻게 호출할 것이냐? 람다식은 익명 객체이고, MyFunction은 인터페이스이기 때문에 클래스에 구현되어야 사용할 수 있다. 이때 구현 클래스를 익명 클래스로 사용하면 max() 메소드를 호출할 수 있다.

하나의 메소드가 선언된 함수형 인터페이스를 정의해서 익명 객체화를 시키는 것이 자바의 규칙을 어기지 않고 자연스럽게 프로그래밍이 가능하기 때문에 함수형 인터페이스는 람다식을 다루기 위한 것이라고 생각하면된다.

@FunctionalInterface
interface MyFunction {
  public abstract int max(int a, int b);
}

주의할점은 오직 하나의 추상 메소드로 정의되어야만 함수형 인터페이스를 사용할 수 있다. 왜? 함수형 인터페이스는 하나의 추상 메소드만 있어야 하는 규칙이 정해져 있기 때문이다.

최종적으로 아래와 같이 람다식을 사용할 수 있다.

MyFunction f = (a, b) -> a > b ? a : b;

익명 객체를 람다식으로 대체

List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd", "aaa");

        Collections.sort(list, new Comparator<String>() {
            @Override
            public int compare(String o1, String o2) {
                return o2.compareTo(o1);
            }
        });
List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd", "aaa");

        Collections.sort(list, (o1, o2) -> o2.compareTo(o1))
Comparator
  • 실제로 인터페이스 안에 compartTo(T o)라는 추상 메소드만 존재한다.
public interface Comparable<T> {
    /**
     * Compares this object with the specified object for order.  Returns a
     * negative integer, zero, or a positive integer as this object is less
     * than, equal to, or greater than the specified object.
     *
     * <p>The implementor must ensure <tt>sgn(x.compareTo(y)) ==
     * -sgn(y.compareTo(x))</tt> for all <tt>x</tt> and <tt>y</tt>.  (This
     * implies that <tt>x.compareTo(y)</tt> must throw an exception iff
     * <tt>y.compareTo(x)</tt> throws an exception.)
     *
     * <p>The implementor must also ensure that the relation is transitive:
     * <tt>(x.compareTo(y)&gt;0 &amp;&amp; y.compareTo(z)&gt;0)</tt> implies
     * <tt>x.compareTo(z)&gt;0</tt>.
     *
     * <p>Finally, the implementor must ensure that <tt>x.compareTo(y)==0</tt>
     * implies that <tt>sgn(x.compareTo(z)) == sgn(y.compareTo(z))</tt>, for
     * all <tt>z</tt>.
     *
     * <p>It is strongly recommended, but <i>not</i> strictly required that
     * <tt>(x.compareTo(y)==0) == (x.equals(y))</tt>.  Generally speaking, any
     * class that implements the <tt>Comparable</tt> interface and violates
     * this condition should clearly indicate this fact.  The recommended
     * language is "Note: this class has a natural ordering that is
     * inconsistent with equals."
     *
     * <p>In the foregoing description, the notation
     * <tt>sgn(</tt><i>expression</i><tt>)</tt> designates the mathematical
     * <i>signum</i> function, which is defined to return one of <tt>-1</tt>,
     * <tt>0</tt>, or <tt>1</tt> according to whether the value of
     * <i>expression</i> is negative, zero or positive.
     *
     * @param   o the object to be compared.
     * @return  a negative integer, zero, or a positive integer as this object
     *          is less than, equal to, or greater than the specified object.
     *
     * @throws NullPointerException if the specified object is null
     * @throws ClassCastException if the specified object's type prevents it
     *         from being compared to this object.
     */
    public int compareTo(T o);
}

함수형 인터페이스 타입의 매개변수

void testMethod(MyFunction f) {
  f.myMethod();
}

메소드의 매개변수로 함수형 인터페이스가 있으면 그 메소드의 매개변수로 람다식을 받는다는 뜻이다.

testMethod를 호출하는 코드를 만들면

MyFunction f = () -> System.out.println("myMethod()");
f.testMethod();

한줄로 합치면

testMethod(() -> System.out.println("myMethod()"));

함수형 인터페이스 타입의 반환타입

MyFunction testMethod() {
  MyFunction f = () -> {}; // 람다식을 반환한다는 뜻
  return f;
}

한줄로 합치면

MyFunction testMethod() {
  return () -> {};
}

변수 캡처 (Variable Capture)

변수 범위

람다는 새로운 변수 범위를 생성하지 않는다. 람다 내에서 변수 사용은 둘러싸고 있는 환경의 변수들을 참조한다. thissuper 키워드도 마찬가지라 따라서 복잡해지지 않음

아래 예제에서 i 는 단순히 람다를 둘러싸고 있는 클래스의 필드를 가리키게 된다.

public static class Example {
  int i = 5;
    
  public Integer example() {
    Supplier<Integer> function = () -> i * 2; // this.i * 2; 도 동일
    return function.get();
  }
  
  public Integer anotherExample(int i) {
    Supplier<Integer> function = () -> i * 2; // this.i 와 다름
    return function.get();
  }
  
  public Integer yetAnotherExample() {
    int i = 15;
    Supplier<Integer> function = () -> i * 2;
    return function.get();
  }
}

유사 파이널(Effectively final)

자바 7에서 익명 클래스의 인스턴스로 넘겨지는 모든 변수들은 final 이어야만 한다. 이유는 익명 클래스의 인스턴스가 필요로 하는 변수 정보나 컨텍스트를 복사해서 넘겨주기 때문이다.. 이런 상황에서 변수가 변경되면 의도하지 않은 결과가 나올 수 있으므로 변경되지 않도록 final 로 선언되어야만 하고, 그렇지 않을 경우 컴파일 에러가 발생한다.

// Java 7
// 필터 메소드는 List<Person> 를 돌면서 조건을 테스트한다.
private List<Person> filter(List<Person> people, Predicate<Person> predicate) {
  ArrayList<Person> matches = new ArrayList<>();
  for (Person person : people) {
    if (predicate.test(person))
      matches.add(person);
  }
  return matches;
}

// 은퇴나이를 기준으로 은퇴한 사람 리스트를 구한다.
public void findRetirees(List<Person> people) {
  int retirementAge = 55; // final 필요
  List<Person> retirees = filter(people, new Predicate<Person>() {
    @Override
    public boolean test(Person person) {
      return person.getAge() >= retirementAge; // compile error
    }
  });
}

class Person {
  private int age;

  public int getAge() {
    return age;
  }
}

위 예제에서는 익명 인스턴스에서 외부의 retirementAge 를 참조할 때 컴파일 에러가 발생한다. 이는 retirementAgefinal 이 아니기 때문에 나는 에러로 final 을 붙여주면 해결

여기서 익명 클래스에 컨텍스트를 넘겨주는 것이 클로저이고, 컴파일러는 이 필요한 정보를 복사해서 넘겨주는데 이를 Variable capture 라고 한다.

자바 8에서는 유사 파이널 (effectively final) 이라는 개념을 도입해 해당 변수가 변경되지 않다고 컴파일러가 판단하면 해당 변수를 final 로 해석하게 된다. 따라서 자바 8 컴파일러로 변경한 후에는 final 키워드가 없어도 문제 없이 컴파일 가능

물론 여기서 변수를 초기화한 후 나중에 수정하는 경우라면 해당 변수를 유사 파이널로 볼 수 없다.

파이널 우회

이렇게 강요되는 final 이 회피되는 경우가 있다. 만약 사람들의 전체 나이 합을 구한다고 하면

private static int sumAllAges(List<Person> people) {
  int sum = 0;
  for (Person person : people) {
    sum += person.getAge();
  }
  return sum;
}

여기에서 반복 동작을 추상화해서 외부로 빼서, 나이 합을 구하는 로직을 람다로 받을 수 있게 변경할 수 있다.

public final static Integer forEach(List<Person> people, Function<Integer, Integer> function) {
  Integer result = null;
  for (Person person : people) {
    result = function.apply(person.getAge());
  }
  return result;
}

이렇게 만든 함수는 다음과 같이 사용할 수 있다.

private static void badExample(List<Person> people) {
  Function<Integer, Integer> sum = new Function<Integer, Integer>() {
    private Integer sum = 0; // 합이 저장됨

    @Override
    public Integer apply(Integer amount) {
      sum += amount;
      return sum;
    }
  };

  forEach(people, sum); // 결과는 정상적으로 출력될 것
}

문제는 이 합하는 연산 때문에 생긴다. 합을 하기 위해서는 값을 지속적으로 저장할 곳이 필요하다 이 예제에서 sum 변수는 함수가 호출될 때마다 계속해서 재사용되고 변경된다. 이유는 동일한 인스턴스의 필드를 참조하고 있기 떄문, 따라서 동작은 제대로 하지만 이는 람다로 변경할 수 없다. 람다 내부에는 누적해서 저장할 공간이 없기 때문이다,

람다를 사용하면 외부에 있는 변수에 저장해야하는데..

int sum = 0;
// Variable used in lambda expression should be final or effectively final
forEach(people, x -> sum += x);

그렇다면 위와 같은 에러를 만날 수 있다. 이 때 sum 은 람다 내부에서 변경되고 있기 때문에 유사 파이널이 아니다. 하지만 그렇다고 이 변수를 final 로 변경한다면 람다식 내부에서 변경(합)을 못하게 될 것이다. 그렇다면 어떻게 해야 할까?

final int sum = 0;
forEach(people, x -> sum += x);

이 문제를 해결할 방법은 바로 일반 자료형 타입 대신 객체나 배열을 사용하는 방법이다. 이는 참조변수이기 때문에 final 로 선언 시 참조는 변경되지 않고 해당 값은 변경할 수 있게 된다.

int[] sum = {0};
forEach(people, x -> sum[0] += x); // ok

하지만 배열 sum 에 대해서 다른 곳에서 변경할 수 있는 side effect 가 존재한다. 유사 파이널 설명을 위해서 코드를 살펴봤지만, 이런 작업은 stream API 를 이용해서 하는 것이 더 효과적이다. 다음과 같은 코드가 될 겁니다.

int sum = people.stream()
  .map(person -> person.getAge())
  .reduce(0, Integer::sum);

메소드, 생성자 레퍼런스

위에서 람다식에 대해 알아보았듯이. 구현해야 하는 메소드의 본문을 람다 표현식으로 (익명클래스로 생성되도록 하는) 대체할 수 있다는 것을 알았다. 이미 우리가 구현하고자 하는 람다식 자체가 구현되어있는 경우가 있습니다. 이럴때 사용하는 메서드 참조용 특수 문법이 있는데, 이 방식을 메소드 참조라고 표현한다.

String [] strings = new String [] { // 리스트를 순회하며 요소를 출력하는 단순한 예제 코드이다.
    "6", "5", "4", "3", "2", "1"
};

List<String> list = Arrays.asList(strings);

for(String s : strings)
    System.out.println(s);

하지만 반복적인 for문의 사용과 iterator는 굉장히 지친다.

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

그래서 자바8의 ArrayList 클래스에는 각 요소에 함수를 적용하는 forEach 메소드를 지원한다. 코드를 조금 수정하자면

String [] strings = new String [] {
    "6", "5", "4", "3", "2", "1"
};

List<String> list = Arrays.asList(strings);

list.forEach(x -> System.out.println(x));

이런식으로 간결해 지는것을 볼 수 있습니다. 하지만, 결과적으로 따지고 본다면, forEach메소드는 매개변수로 Consumer interface를 전달받는다. 물론 람다식을 이용하여, Consumer가 가지고있는 accept 메소드 구현체를 직접 전달함으로써 해결하고 있지만
지금 전달한 람다식의 내용은 list의 요소를 입력받아, 단순히 println메소드에 전달해주는 역할만 하고 있게 된다.
즉, Consumer가 구현해야 되는 accept메소드가 실행될때 println메소드를 한번더 실행해주는 형태라고 보시면 될 것 같다
그렇다면 결국 메소드의 call stack이 1depth 깊어진 결과라고 보여지는데, 그냥 System클래스가 가진 println메소드를 forEach에게 전달할 순 없을까?

그게 바로 메소드 참조이다.

String [] strings = new String [] {
    "6", "5", "4", "3", "2", "1"
};

List<String> list = Arrays.asList(strings);

list.forEach(System.out::println);

이렇게 표현해 주는 것만으로, forEach에게 println을 전달해 주게 되고
이러한 예에서 볼 수 있듯이 :: 연산자는 메소드 이름과 클래스를 분리하거나, 메소드 이름과 객체의 이름을 분리한다.
이는 다음과 같이 세 가지 형태로 사용할 수 있다.

\1. 클래스::인스턴스메소드 (public)

\2. 클래스::정적메소드 (static)

\3. 객체::인스턴스메소드 (new)

첫 번째 형태에서는 첫번째 파라미터가 메소드의 수신자가 되고, 나머지 파라미터는 해당 메소드로 전달 된다.
ex) String::compareToIgnoreCase는 (x, y) -> x.compareToIgnoreCase(y) 와 같다.

두 번째 형태에서는 모든 파라미터가 정적 메소드로 전달된다.
ex) Object::isNull은 x -> Object.isNull(x) 와 같다.

세 번째 형태에서는 주어진 객체에서 메소드가 호출되며 파라미터는 인스턴스 메소드로 전달 된다.
ex) System.out::println은 x -> System.out.println(x)와 동일하다.

즉 우리는 세 번째 방식인 객체의 인스턴스 메소드를 전달해준 셈.

생성자 참조

@Data
class Person {
    private String name;
    private String gender;

    public Person() {}

    public Person(String name) {
        this.name = name;
    }

    public Person(String name, String gender) {
        this.name = name;
        this.gender = gender;
    }
}

person 클래스를 생성하여 사용하는 방법은, 생성자에 정의된 방법과 동일하다.

Person member = new Person();
Person member1 = new Person("dolen1");
Person member2 = new Person("dolen2", "man");

하지만 우리는 람다식을 공부했기 때문에 아래와 같이 표현될 수 있다.

Function<String, Person> myFunction = name -> new Person(name);

또한 생성자는 메소드이기 때문에 메소드 참조를 할 수 있다.

Function<String, Person> myFunction = Person::new;
myFunction.apply("dolen");

BiFunction<String, String, Person> myFunction1 = Person::new;
myFunction1.apply("dolen", "man");

참고자료 🧾

0개의 댓글