티스토리 뷰

아래의 글에서, 책에서 다루는 내용 외에 람다가 어떻게 구현되는지를 깊게 다룹니다. 채널 고정~ ^^7

 

익명 클래스

위의 글을 보고 오면 익명 클래스(anonymous classes)를 이해하기 쉽다.

 

익명 클래스를 통해 콜렉션을 정렬하는 예시이다.

public static void main(String[] args) {
    List<String> words = Arrays.asList(args);

    // 코드 42-1 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다! (254쪽)
    Collections.sort(words, new Comparator<String>() {
        public int compare(String s1, String s2) {
            return Integer.compare(s1.length(), s2.length());
        }
    });
}

인터페이스의 abstract method를 그때그때 구현해서 사용하는 방법이다. 그러나 코드가 너무 복잡하다. 이를 해소하기 위해 자바 8부터 lambda가 추가됐다.

 

// 코드 42-2 람다식을 함수 객체로 사용 - 익명 클래스 대체 (255쪽)
Collections.sort(words,
        (s1, s2) -> Integer.compare(s1.length(), s2.length()));

코드가 한츰 더 간결해지고 이해하기 쉬워졌다. List 내부의 String의 길이를 통해 정렬을 함이 명시적이다.

위의 람다식에서 파라미터의 타입을 생략할 수 있는데 이는 컴파일러가 문맥을 분석해 자동으로 타입을 추론해 준다.

 

// 람다 자리에 비교자 생성 메서드(메서드 참조와 함께)를 사용 (255쪽)
Collections.sort(words, comparingInt(String::length));

// 비교자 생성 메서드와 List.sort를 사용 (255쪽)
words.sort(comparingInt(String::length));

이에 더해 위의 코드와 같이 비교자 생성 메소드를 이용하면 더 짧은 코드를 쓸 수 있다.

 

 

 

// 코드 34-6 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입 (215-216쪽)
public enum Operation {
    PLUS("+") {
        public double apply(double x, double y) { return x + y; }
    },
    MINUS("-") {
        public double apply(double x, double y) { return x - y; }
    },
    TIMES("*") {
        public double apply(double x, double y) { return x * y; }
    },
    DIVIDE("/") {
        public double apply(double x, double y) { return x / y; }
    };

    private final String symbol;

    Operation(String symbol) { this.symbol = symbol; }

    @Override public String toString() { return symbol; }

    public abstract double apply(double x, double y);
}

아이템 34의 Operation 코드이다. 이를 lambda를 통해 개선하면,

// 코드 42-4 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입 (256-257쪽)
public enum Operation {
    PLUS  ("+", (x, y) -> x + y),
    MINUS ("-", (x, y) -> x - y),
    TIMES ("*", (x, y) -> x * y),
    DIVIDE("/", (x, y) -> x / y);

    private final String symbol;
    private final DoubleBinaryOperator op;

    Operation(String symbol, DoubleBinaryOperator op) {
        this.symbol = symbol;
        this.op = op;
    }

    @Override public String toString() { return symbol; }

    public double apply(double x, double y) {
        return op.applyAsDouble(x, y);
    }
}

코드가 훨씬 간결해졌다. DoubleBinaryOperator는 JAVA에서 제공하는 기본 funtional interface이다.

 

Funtional Interface란?

Functional Interface

Functional Interface의 정의는 다음과 같다.

  • 추상 메소드(abstract method)를 하나만 지닌다.
  • default method, static method, Object의 method는 정의되어 있어도 상관없다.

다음과 같은 성질을 만족한다면 Functional Interface라고 부를 수 있다. 람다는 Functional Interface의 성질을 만족하는 인터페이스의 구현체로써 사용될 수 있다.

 

이때 @FunctionalInterface를 달면 해당 인터페이스가 위의 성질을 만족하는지 컴파일 타임에 알 수 있게 해 준다. 달지 않아도 성질을 만족하면 사용할 수 있지만 오류는 컴파일 타임에 아는 것이 좋다. 커스텀 Functional Interface을 구현할 것이라면 꼭 다는 것이 좋다.

 

JAVA에서는 이런 Functional Interface를 미리 다양한 상황에 대응될 수 있도록 정의해 놨다.

java.util.function 패키지

Functional Interface을 사용해야 할 일이 있다면 여기서 찾아보고 쓰는 것이 좋겠다. 위에서 Operation을 개선할 때 사용한  DoubleBinaryOperator 또한 해당 패키지에 존재한다.

 

 

 

 

익명 클래스의 사용처

그렇다면 항상 람다를 사용해야 하는가? 그렇지 않다. 아래는 익명 클래스를 사용하기 적절한 사례이다.

  • 코드의 라인수가 길어진다면 익명 클래스를 사용하는 것이 좋다.
    • 람다는 클래스나 메소드와 다르게 이름이 없고 문서화를 할 수 없다. 따라서 코드 한 두 줄로 어떤 동작을 하는지 명확히 파악할 수 없는 경우에는 람다를 사용하면 안 된다. 가독성 최악!
  • 추상 클래스(abstract class)를 사용할 때
    • 람다는 funtional interface에서만 동작한다. 이럴 때는 익명 클래스를 통해 추상 클래스를 구현해 사용한다.
  • 추상 메소드가 여러 개인 인터페이스를 사용할 때
    • 람다는 funtional interface에서만 동작한다. 위에서 설명한 functional Interface의 성질이 아니다.
  • this로 자기 자신을 참조해야 할 때
    • 아래에서 다루겠지만 람다의 this는 자기 자신을 반환하는 것이 아닌 선언된 스코프의 인스턴스를 반환한다. 자기 자신 참조가 필요한 경우에서는 익명클래스를 사용해야 한다.

 

 

// 추가

람다와 익명 클래스의 차이점

 

그렇다면 람다가 기존 익명 클래스 생성 문법을 치환하는 역할인가 궁금해져 아래의 코드를 통해 실험해 봤다.

public class TestClass {

    public static void printFunctionalInterface(DoubleBinaryOperator hi){
        System.out.println("인스턴스 : " + hi);
    }

    public static void main(String[] args) {
        printFunctionalInterface((d1, d2) -> 1);
        printFunctionalInterface(
                new DoubleBinaryOperator() {
                    @Override
                    public double applyAsDouble(double left, double right) {
                        return 1;
                    }
                }
        );
    }
    //인스턴스 : tt.TestClass$$Lambda$14/0x0000000800066c40@3fb6a447
    //인스턴스 : tt.TestClass$1@6b2fad11
}

이게 뭐지?.. 익명 클래스의 경우에 예상한 출력이 나왔지만 람다는 무엇인가 이상하다.

 

 

 

아래의 코드를 통해 차이점을 살펴보자

public class ThisDifference {
       public static void main(String[] args) {
         new ThisDifference().print();
     }
     public void print() {
         Runnable anonClass = new Runnable(){
             @Override
             public void run() {
                 verifyRunnable(this);
                 // true
             }
         };
         anonClass.run();

         Runnable lambda = () -> verifyRunnable(this); // false
         lambda.run();
     }
     private void verifyRunnable(Object obj) {
         System.out.println(obj instanceof Runnable); 
    }
 }

순서대로 true와 false를 반환한다. 위에서 람다의 this는 람다식이 선언된 스코프의 인스턴스인 ThisDifference가 반환되고 익명 클래스의 경우는 Runnable의 구현체가 반환된다. 왜 이런 차이가 발생할까?

 

 

람다식은 컴파일 타임에 객체가 생성되는 것이 아닌 invokedynamic를 사용해 런타임에 동적으로 클래스를 정의하고 인스턴스를 생성한다. invokedynamic가 무엇인지 잘 몰라도 된다. 예제를 보며 이해하자

 

컴파일 타임

컴파일러는 람다식을 invokedynamic로 변환해 람다 객체를 생성하는 방법을 만들어둔다. 컴파일 타임에 람다식이 선언된 내부 메소드를 복사해서 선언 된 스코프의 static method로 추가한다.

public class Test {
     public static void main(String[] args) {
         Test test = new Test();
         test.testMethod();
     }
     public void testMethod() {
         Runnable runnable = () -> {
             System.out.println("this: " + this);
             throw new RuntimeException();
         };
         System.out.println("class: " + runnable.getClass());
         runnable.run();
     }
     
     // 람다 내부 선언식을 복사해
     // 이렇게 만들어 준다.
     // public static lambda$testMethod$0(){
     //  System.out.println("this: " + this);
     //  throw new RuntimeException();
     //}
 }

위의 코드를 보고 이해가 되지 않는 부분이 있다. Runnable의 구현체는 어디 있는가? run()은 언제 실행되는가? 그 해답은 런타임에 동적으로 생성된 람다 클래스에 있다.

 

런타임

import java.lang.invoke.LambdaForm.Hidden;

 // $FF: synthetic class
 final class Test$$Lambda$1 implements Runnable {
     private final Test arg$1;
     private Test$$Lambda$1(Test var1) {
         this.arg$1 = var1;
     }
     private static Runnable get$Lambda(Test var0) {
         return new Test$$Lambda$1(var0);
     }
     @Hidden
     public void run() {
         this.arg$1.lambda$testMethod$0();
     }
 }

런타임에 동적으로 생성된 클래스를 파일로 변환시키면 다음과 같은 코드가 만들어진다. Runnable을 구현하는 Test$$Lambda$1이다. 생성자를 통해 람다식이 선언된 Test 클래스를 받는다. Test 클래스의 invokedynamic를 통해 생성된 lambda$testMethod$0()를 호출한다. 이럴 수가. 이렇게 되면 람다 this의 의문도 풀린다. 애초에 Runnable의 run()에서 Test의 메소드를 호출하고 있다. 그렇기 때문에 this가 호출되면 Test의 인스턴스 주소가 반환된다.

 

 

 

왜 이렇게 만들었을까?

효율적이기 때문이다. 다음의 코드를 살펴보자.

public void loopLambda() {
     myStream.forEach(item -> item.doSomething());
 }
public void loopAnonymous() {
     myStream.forEach(new Consumer<Item> () {
          @Override
         public void accept(Item item) {
             item.doSomething();
         }
     }
}

만약 myStream의 길이가 100만 개가 된다면 익명 객체를 통해 Consumer를 생성하게 되면 100만 개의 객체가 생성됐다 소멸되는 것이 반복될 것이다. 그러나 람다의 경우는? invokedynamic을 통해 생성된 static 메소드를 하나의 객체가 반복 호출한다. 객체의 생성이 100만 개에서 한 개로 줄어든 것이다.

 

 

 

람다의 Capturing

Capturing이 뭔데요??

public class Test {
     public static void main(String[] args) {
         Test test = new Test();
         test.testMethod();
     }
     public void testMethod() {
         int a = 10;
         Runnable runnable = () -> {
             System.out.println("a: " + a);
         };
         runnable.run();
     }
 }

 

 

 

위의 코드처럼 람다의 선언 외부 스코프의 변수를 참조하는 것이다. 이건 어떻게 구현돼 있을까?

 

import java.lang.invoke.LambdaForm.Hidden;
 // $FF: synthetic class
 final class Test$$Lambda$1 implements Runnable {
     private final int arg$1;
     private Test$$Lambda$1(int var1) {
         this.arg$1 = var1;
     }
     private static Runnable get$Lambda(int var0) {
         return new Test$$Lambda$1(var0);
     }
     @Hidden
     public void run() {
         Test.lambda$testMethod$0(this.arg$1);
     }
 }

클래스의 생성자를 통해 외부 스코프의 값을 받아 필드로 저장한다. 자바로 코딩 테스트 풀 때 람다 식 내부에서 외부 스코프의 값을 변경하려고 하면 컴파일 에러가 났었다.

 

아하!! 애초에 필드에 복사해서 값을 final로 지니는 방식으로 구현돼 있기 때문에 이를 막아둔 것이다. 생성된 static 메소드에서 외부 스코프의 값을 바꿔도 Lambda 클래스의 값은 바뀌지 않아서 싱크가 안 맞는다. 람다식에서 외부 스코프의 값을 capture 하기 위해선 final value만 사용해야 한다고 이해하는 게 편하겠다.

 

 

결론

람다의 생성방식을 이해하니 가독성 측면뿐만 아니라 성능적인 효율이 존재함을 알았다. 람다 팍팍 써야겠다

 

 

참고

https://dreamchaser3.tistory.com/5

https://d2.naver.com/helloworld/4911107

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함