자바 익명 클래스와 람다

Kim Dong Kyun·2023년 9월 5일
1

개요

이미지 출처

자동차 경주 게임 리팩토링 중, 전략 패턴 을 사용해서 랜덤값에 대한 테스트 코드를 작성하는 부분을 보았다. 요구사항은 아래와 같다

...
4.전진하는 조건은 0에서 9 사이에서 random 값을 구한 후 random 값이 4이상일 경우이다.

...

random 한 값을 어떻게 테스트 해야 할까? 에 대해서 여러 대안이 있었지만 (ex : 시행 횟수를 늘려서 평균값을 측정한다, 매서드의 인자로 시행 결과를 받는다 등등...) 그 중 전략 패턴을 사용한 부분을 한번 살펴보자.

public class Car {
    private final String name;
    private final int position;

    public Car(String name, int position) {
        this.name = name;
        this.position = position;
    }

    public Car move(MoveStrategy moveStrategy) {
        if (moveStrategy.isMovable()) {
            return new Car(name, position + 1);
        }
        return this;
    }
...
}

public interface MoveStrategy {
    boolean isMovable();
}
  • 위에서 보는 바와 같이, MoveStrategy라는 인터페이스를 매서드의 패러미터로 설정하고 인터페이스의 isMovable() 이라는 boolean 매서드가 true 일때만 position을 +1 한다.

  • 그렇다면 이 부분은 테스트 코드에서 어떻게 활용 될까?


익명 클래스

...
    @Test
    public void 이동() {
        Car car = new Car("pobi", 0);
        Car actual = car.move(new MoveStrategy() {
            @Override
            public boolean isMovable() {
                return true;
            }
        });
        assertThat(actual).isEqualTo(new Car("pobi", 1));
    }

    @Test
    public void 정지() {
        Car car = new Car("pobi", 0);
        Car actual = car.move(new MoveStrategy() {
            @Override
            public boolean isMovable() {
                return false;
            }
        });
        assertThat(actual).isEqualTo(new Car("pobi", 0));
    }
    ...
}

위와 같이 new MoveStrategy(){ 구현내용 }; 으로 매서드 스코프에서 해당 객체(인터페이스)의 추상 매서드를 구현하여 사용 가능하다. 얘를 익명 클래스라고 한다.

  • 실제 비즈니스 로직에서의 Movable 은 랜덤으로 값이 5 이상인지를 판별하지만

  • 테스트 코드에서는 "랜덤"의 요소를 없애서 반복해서 테스트해도 안전한 코드로 재사용 가능한 것이다. 심지어 인터페이스를 @Override 하는 구현체 클래스를 만들 필요 없이!

따라서 익명 클래스의 장점은 아래와 같다


1. 구현체 클래스를 명시 할 필요가 없다 (메모리 낭비가 적다)

클래스 내의 클래스, 내부 클래스 및 익명 클래스는 아래와 같이 디컴파일 된다.

CarTest 는 분명 하나의 클래스 파일로 디컴파일 되었는데,(CarTest.class) $1 $2 처럼 다른 녀석들이 보인다. 이 녀석들의 정체가 바로 익명 클래스이다.

익명 클래스는 상위 클래스(위 예제에서는 CarTest 클래스)에 $1 $2 와 같이 순서를 붙인 채로 디컴파일 된다.


2. 유연하다

익명 클래스는 인터페이스, 추상 클래스 등 추상에도 선언이 가능하고, 구상 클래스를 @Override 할 수도 있다.

따라서 개발자는 구상 클래스에 존재하는 기존 매서드를 그대로 사용하는 것이 아닌 즉석에서 고쳐서 "특정" 매서드에서만 일회용으로 사용이 가능하게 된다.


그런데, 위의 익명 클래스를 이용한 테스트 클래스를 선언 한 후 살펴보면, IDE에서 다음과 같이 알려준다.

"익명 'new MoveStrategy()' 는 lambda로 대체 될 수 있다"

그렇다면 한번 해보자.

오! 훨씬 모습이 간결해졌다. 람다는 어떤 녀석일까?


람다

한번 그냥 써보자. 리팩토링과 함께

    public static int sumAll(List<Integer> numbers) {
        int total = 0;
        for (int number : numbers) {
            total += number;
        }
        return total;
    }

    public static int sumAllEven(List<Integer> numbers) {
        int total = 0;
        for (int number : numbers) {
            if (number % 2 == 0) {
                total += number;
            }
        }
        return total;
    }

    public static int sumAllOverThree(List<Integer> numbers) {
        int total = 0;
        for (int number : numbers) {
            if (number > 3) {
                total += number;
            }
        }
        return total;
    }

이게 내 오늘 과제이다. 위 매서드를 어떻게 리팩토링하는 것이 좋을까?

당장 보이는 문제점은

  • 중복되는 부분이 많다
    : numbers 를 패러미터로 하는 매서드들이 total에 어떤 계산을 거쳐서, 리턴한다는 점이 중복된다

  • 유일한 창이는 조건문이다
    : sumAll 은 조건문 없이 total에 추가하고, 다른 녀석들은 조건에 따라서 추가한다

그렇다면, 조건만 따로 분리하고 나머지는 동일하게 공통화(일반화) 해도 되겠다. 매서드를 하나로 빼보자

    public static int sumByCondition(List<Integer> numbers){
        int total = 0;
        for (Integer number : numbers) {
            if (무슨무슨조건(number)){
                total += number;
            }
        }
        return total;
    }
  • 이놈은 위 놈들이랑 완전히 같은 모양이다. "무슨무슨조건"에 어떤 녀석이 들어올지만 가변적으로 변신 시켜주면 될 것 같다.

  • 오? 가변적으로 변경이면...익명 클래스가 생각난다. 받는 쪽에서
    new 조건 (){ 이렇게 저렇게 하자} 라고 재정의하면 될 것 같은데?

  • 그렇다면 인터페이스를 하나 파보자

public interface SumWithCondition {
    boolean sumCondition(int number);
}
  • 조건에 따라 더해주는 인터페이스를 선언했다.

  • 넘버를 받아서, 해당 넘버가 적절하면 true 아니면 false를 리턴 해 줄 것이다.

  • 이제 적용시켜보자

    public static int sumByCondition(List<Integer> numbers){
        int total = 0;
        SumWithCondition sumWithCondition = new SumWithCondition() {
            @Override
            public boolean sumCondition(int number) {
                return true;
            }
        };
        for (Integer number : numbers) {
            if (sumWithCondition.sumCondition(number)){
                total += number;
            }
        }
        return total;
    }

위 형태는 sumWithAll 과 완전히 동일한 역할을 한다.

  • 일단, 너무 보기 힘들다. 람다로 바꿔볼까?
    public static int sumByCondition(List<Integer> numbers){
        int total = 0;
        SumWithCondition sumWithCondition = number -> true;
        for (Integer number : numbers) {
            if (sumWithCondition.sumCondition(number)){
                total += number;
            }
        }
        return total;
    }

위 코드는 훨씬 보기 편해졌다.

그런데, 우리가 원하는 건 각각의 함수에 람다로 뭔가를 하는 게 아니라, 공통 기능을 하나의 함수로 뽑고 나머진 그 함수를 이용하는 것이다.

  • 그런데, 각각의 녀석들은 다 조건이 달라서 깔끔하게 줄이기가 너무 어렵다.

그렇다면, 람다의 특징 중 하나인 "일급 객체"(혹은 일급 시민) 으로 활용된다는 점을 이용해보자


일급 객체로써의 람다

일급 객체의 조건은 다음과 같다.

변수나 데이타에 할당 할 수 있어야 한다.
객체의 인자로 넘길 수 있어야 한다.
객체의 리턴값으로 리턴 할수 있어야 한다..

우리는 여기서 "객체의 인자로 넘길 수 있어야 한다" 를 주목해보자.

  • 만약 우리가 만든 매서드 내에서 인터페이스를 구현하는 어떤 함수가 들어오는지를 "모를 수" 있다면?

  • 위와 같이 모르게 해주기 위해서, 인자로 한번 넣어주자

    public static int sumByCondition(List<Integer> numbers, SumWithCondition sumWithCondition){
        int total = 0;
        for (Integer number : numbers) {
            if (sumWithCondition.sumCondition(number)){
                total += number;
            }
        }
        return total;
    }

위와 같이. 그리고 사용하는 측에서는 이렇게 사용해보자

    public static int sumAll(List<Integer> numbers) {
        return sumByCondition(numbers, new SumWithCondition() {
            @Override
            public boolean sumCondition(int number) {
                return true;
            }
        });
    }

    public static int sumAllEven(List<Integer> numbers) {
        return sumByCondition(numbers, new SumWithCondition() {
            @Override
            public boolean sumCondition(int number) {
                return number % 2 == 0;
            }
        });
    }

    public static int sumAllOverThree(List<Integer> numbers) {
        return sumByCondition(numbers, new SumWithCondition() {
            @Override
            public boolean sumCondition(int number) {
                return number > 3;
            }
        });
    }
  • 익명 클래스를 통해서 매서드단마다 인터페이스의 매서드를 구현해준다.

  • 조건절은 오버라이드 한다.

위를 통해서, 인터페이스의 구현체가 필요 할 때마다 재정의(@Override)를 쉽고 편하게 할 수 있다.

그런데, 조금 보기 불편한데?

    public static int sumAll(List<Integer> numbers) {
        return sumByCondition(numbers, number -> true);
    }

    public static int sumAllEven(List<Integer> numbers) {
        return sumByCondition(numbers, number -> number % 2 == 0);
    }

    public static int sumAllOverThree(List<Integer> numbers) {
        return sumByCondition(numbers, number -> number > 3);
    }

위와 같이 람다식으로 변환 가능하다.

위의 케이스를 보면서 람다의 사용법을 정리해보자


람다의 특징

1. 람다는 타입 추론이 가능하다.

: 컴파일러 측에서 타입을 추론하여 넣어준다.

우리가
위와 같이 인터페이스의 타입을 정의했으므로 (리턴은 booelan, 인자는 int로 받아라)

위 람다식에서도 함수의 타입을 명시하지 않고 사용이 가능하다. 물론, 명시적으로 써 줄 수도 있다.

2. 람다는 일급 객체이다

: 다시 한 번,일급 객체의 조건은 다음과 같다.

변수나 데이타에 할당 할 수 있어야 한다.
객체의 인자로 넘길 수 있어야 한다.
객체의 리턴값으로 리턴 할수 있어야 한다..

람다는 기본적으로 익명 클래스이다. 그리고 익명클래스는 위의 조건을 만족하는 일급 객체이다.

추가적으로, Functional Interface란?

위 어노테이션은 해당 인터페이스가 함수형 인터페이스임을 알려준다.

함수형 인터페이스는

오직 하나의 추상 매서드를 가진다.

따라서 컴파일러가 하나 이상의 추상 매서드를 감지하면, 컴파일 에러를 내주게 하는 어노테이션이다. (Default 매서드 등 구상 매서드는 가질 수 있다)

0개의 댓글