본문 바로가기
스터디/[white-ship] 자바 스터디(study hale)

15주차 과제: 람다식

by doyoungKim 2021. 3. 14.

목표

자바의 람다식에 대해 학습하세요.

학습할 것 (필수)

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

 

람다란 무엇일까?

람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다. 람다 표현식에는 이름은 없지만 파라미터 리스트, 바디, 반환형식, 발생할 수 있는 예외 리스트는 가질 수 있다.

  • 익명
    보통의 메서드와 달리 이름이 없으므로 익명 이라 표현한다. 구현해야 할 코드에 대한 걱정거리가 줄어든다.
  • 함수
    람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 받, 반환 형식, 가능한 예외 리스트를 포함한다.
  • 전달
    람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성
    익명 클래스 처럼 많은 자질구례한 코드를 구현할 필요가 없다.

 

람다 라는 용어는 람다 미적분학 학계에서 개발한 시스템에서 유래했다. 람다표현식이 왜 중요할까? 

아래의 코드를 보면 알 수 있다.

        // 익명 클래스
        Comparator<Apple> byWeight = new Comparator<Apple>() {
            @Override
            public int compare(Apple o1, Apple o2) {
                return o1.getWight().compareTo(o2.getWight());
            }
        };

        // lambda
        Comparator<Apple> byWeight = (o1, o2) -> o1.getWight().compareTo(o2.getWight());

 

익명 클래스를 사용해서 동작 파라미터 형식의 코드를 만드는 것보다 람다를 이용해서 간결한 방식으로 코드를 전달 할 수 있다.

 

람다는 세 부분으로 이루어진다.

  • 파라미터 리스트: Compator 의 compare 메서드의 파라미터 (두 개의 사과).
  • 화살표: 화살표(->) 는 람다의 파라미터 리스트와 바디를 구분한다.
  • 람다의 바디: 두 사과의 무게를 비교한다. 람다의 반환값에 해당하는 표현식이다.

 

예시 1.

(String s) -> s.length();

String 형식의 파라미터 하나를 가지며 int 를 반환한다. 람다 표현식에는 return이 함출되어 있으므로 return 문을 명시적으로 사용하지 않아도 된다.

 

예시 2.

(Apple a) -> a.getWight() > 150;

Apple 형식의 파라미터 하나를 가지며 boolean (사과가 150그램보다 무거운지 결정)을 반환한다.

 

예시 3.

(int x, int y) -> {
    System.out.println("RESULT: ");
    System.out.println(x + y);
}

int 형식의 파라미터 두 개를 가지며 리턴값이 없다.(void 리턴). 이 예제에서 볼 수 있듯이 람다 표현식은 여러 행의 문장을 포함할 수 있다.

 

예시 4.

() -> 42

파라미터가 없으며 int를 반환한다.

 

예시 5.

(Apple a1, Apple a2) -> a1.getWight().compareTo(a2.getWight());

Apple 형식의 파라미터 두 개를 가지며 int (두 사과의 무게 비교 결과)를 반환한다.

 

람다의 사용처.

함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

함수형 인터페이스

오직 하나의 추상 메서드만 지정한 인터페이스를 뜻한다. (디폴트 메서드는 존재해도 상관 없다.)

@FunctionalInterface
public interface Predicate<T> {
	boolean test (T t);
}

@FunctionalInterface
public interface Comparator<T> {
	int compare (T o1, T o2);
}

@FunctionalInterface
public interface Runnable {
	void run ();
}

@FunctionalInterface
public interface Callable<T> {
	V call ();
}

@FunctionalInterface
public interface PrivilegedAction<T> {
	T run ();
}

 

함수형 인터페이스로 뭘 할 수 있을까? 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급 (기술적으로 따지면 함수형 인터페이스를 concrete 구현한 클래스의 인스턴스) 할 수 있다.
함수형 인터페이스보다는 덜 깔끔하지만 익명 내부 클래스로도 같은 기능을 구현할 수 있다. 

Concrete 관련해서는 아래 링크를 통해서 확인 할 수있다.

 

Concrete Class

 

ixtears23.github.io

 

다음은 오직 하나의 추상 메서드 run을 정의하는 Runnable 이라는 함수형 인터페이스의 예제이다.

public class UseRunnable {

    public static void process(Runnable r) {
        r.run();
    }

    public static void main(String[] args) {

        Runnable r1 = () -> System.out.println("Hello World 1");

        Runnable r2 = new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello world 2");
            }
        };


        process(r1); // Hello World 1
        process(r2); // Hello World 2

        process(() -> System.out.println("Hello World 3")); // Hello World 3

    }
}

 

@FunctionalInterface 는 무엇인가?

함수형 인터페이스임을 가르키는 어노테이션이다. @FunctionalInterface로 인터페이스를 선언했지만 실제로 함수형 인터페이스가 아니면 컴파일러가 에러를 발생시킨다. 예를 들어 추상 메서드가 한 개 이상이라면 "Multiple nonoverriding abstract methods found in interface Foo  (인터페이스 Foo 에 오버라이드 하지 않은 여러 추상 메서드가 있음)" 같은 에러가 발생할 수 있다.

 

실행 어라운드 패턴

자원 처리 (예를 들면, 데이터베이스의 파일 처리) 에 사용하는 순환 패턴 (recurrent pattern) 은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어진다. 설정과 정리 과정은 대부분 비슷하다. 즉, 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는다.
아래와 같은 형태의 코드를 실행 어라운드 패턴 (execute around pattern) 이라고 부른다. 이를 사용하면 자원을 명시적으로 닫을 필요가 없으므로 간결한 코드를 구현하는 데 도움을 준다.

    public static String processFile() throws IOException {
        try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
            return br.readLine();
        }
    }

 

1. 동작 파라미터화

현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다. 한 번에 두 줄을 읽거나 자주 사용되는 단어를 반환하려면 어떻게 해야할까?

기존의 설정, 정리 과정은 재사용하고 processFile 메서드만 다른 동작을 수행하도록 명령할 수 있으면 좋을 것이다.
이미 익숙한 시나리오 아닌가? 그렇다. processFile의 동작을 파라미터화하는 것이다. processFile 메서드가 BufferedReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

람다를 이용해서 동작을 전달 할 수 있다. processFile 메서드가 한 번에 두 행을 읽도록 하려면 코드르 어떻게 고쳐야 할까?
우선 BufferedReader를 인수를 받아서 String을 반환하는 람다가 필요하다. 다음은 BufferedReader에서 두 행을 출력하는 코드이다.

String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());

 

2. 함수형 인터페이스를 이용하여 동작 전달

함수형 인터페이스 자리에 람다를 사용할 수 있다. 따라서 BufferdReader -> String 과 IOException 을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만들어야 한다.

이 인터페이스를 BufferedReaderProcessor 라고 정의하자.

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}

정의한 인터페이스를 processFile 메서드의 전달할 수 있다.

3. 동작 실행

이제 BufferedReaderProcessor에 정의된 process 메서드의 시그니처 (BufferedReader -> String) 와 일치하는 람다를 전달 할 수 있다.
람다의 코드가 processFile 내부에서 어떻게 실행되는지 기억하고 있는가? 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리 한다. 따라서 processFile 바디 내에서 BufferedReaderProcessor 객체의 process 를 호출 할 수 있다.

    public static String processFile(BufferedReaderProcessor p) throws IOException {
        try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
            return p.process(br);
        }
    }

 

4. 람다 전달

이제 람다를 이용해서 다양한 동작을 processFile 메서드로 전달할 수 있다.

다음은 한 행을 처리하는 코드이다.

// 한 행을 처리
String oneLine = processFile((BufferedReader br) -> br.readLine());

// 두 행을 처리
String twoLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());

 

지금 까지 함수형 인터페이스를 이용해서 람다를 전달하는 방법을 확인했다.

 

 

형식 추론

다이아몬드 연산자 처럼 람다에서도 다음과 같이 형식을 추론할 수 가 있다.

// 형식을 추론하지 않음
Comparator<Apple> appleComparator = (Apple a1, Apple a2) -> a1.getWight().compareTo(a2.getWight());

// 형식을 추론함.
Comparator<Apple> appleComparator2 = (a1, a2) -> a1.getWight().compareTo(a2.getWight());

상황에 따라 명시적으로 형식을 포함하는 것이 좋을 때도 있고 형식을 배제하는 것이 가독성을 향상시킬 때도 있다. 어떤 방법이 좋은지 정해진 규칙이 없다. 개발자 스스로 어떤 코드가 가독성을 향상시킬 수 있는지 결정해야 한다.

 

지역 변수 사용

지금까지 살펴본 모든 람다 표현식은 인수를 자신의 바디 안에서만 사용했다. 하지만 람다 표현식에서는 익명 함수가 하는 것 처럼 자유 변수(free variable)  (파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다. 

이와 같은 동작을 람다 캡처링 (capturing lambda, variable capture ...) 이라고 부른다. 다음은 portNumber 변수를 캡처하는 람다 예제다.

int portNumber = 8080;
Runnable r = () -> System.out.println(portNumber);

하지만 자유 변수에도 약간의 제약이 있다. 람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처 (자신의 바디에서 참조할 수 있도록) 할 수 있다.하지만 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다. 즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다. (참고: 인스턴스 변수 캡처는 final 지역 변수 this를 캡처하는 것과 마찬가지다). 예를 들어 다음 예제는 portNumber에 값을 두 번 할당하므로 컴파일할 수 없는 코드다.

int portNumber = 8080;
Runnable r = () -> System.out.println(portNumber);
portNumber;

 

 

람다에서 참고하는 지역 변수는 final로 선언되거나 실질적으로 final처럼 취급되어야 한다.

지역 변수의 제약

왜 지역 변수에 이런 제약이 필요한지 이해할 수 없는 독자도 있을 것이다. 우선 내부적으로 인스턴스 변수와 지역 변수는 태생부터 다르다.
인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치한다. 람다에서 지역 변수에 바로 접근할 수 있다는 가정 하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다. 따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.

 

메서드 래퍼런스

메서드 레퍼런스를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다. 때로는 람다 표현식보다 메서드 레퍼런스를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다. 다음은 메서드 레퍼런스와 새로운 자바 8 API 를 활용한 정렬 예제이다.

// 기존 코드
inventory.sort((Apple a1, Apple a2) -> a1.getWight().compareTo(a2.getWight()));

// 메서드 레퍼런스
inventory.sort(comparing(Apple::getWight));

 

메서드 레퍼런스가 왜 중요한가? 메서드 레퍼런스는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있다. 예를 들어 람다가 '이 메서드를 직접 호출해' 라고 명령한다면 메서드를 어떻게 호출해야 하는지 설명을 참조하기보다는 메서드명을 직접 참조하는 것이 편리하다. 실제로 메서드 래퍼런스를 기존 메서드 구현으로 람다 표현식을 만들 수 있다. 이때 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다. 메서드 레퍼런스는 어떻게 활용할까? 메서드명 앞에 구분자 (::) 를 붙이는 방식으로 메서드 래퍼런스를 활용할 수 있다.

// (Apple a) -> a.getWeight()
Apple::getWeight

// () -> Thread.currentThread().dumpStack()
Thread.currentThread()::dumpStack

// (str, i) -> str.substring(i)
String::substring

// (String s) -> System.out.println(s)
System.out::println

 

생성자 래퍼런스

ClassName::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 래퍼런스를 만들 수 있다. 이것은 정적 메서드의 레퍼런스를 만드는 방법과 비슷하다. 

Supplier<Apple> c1 = Apple::new; // 디폴트 생성자 Apple() 의 생성자 레퍼런스
Apple a1 = c1.get(); // Supplier의 get 메서드를 호출해서 새로운 Apple 객체를 만들 수 있다.

위 예제는 다음 코드와 같다.

Supplier<Apple> c1 = () -> new Apple(); // 람다 표현식은 디폴트 생성자를 가진 Apple을 만든다.
Apple a1 = c1.get(); // Supplier의 get 메서드를 호출해서 새로운 Apple 객체를 만들 수 있다.

 

출처

728x90

'스터디 > [white-ship] 자바 스터디(study hale)' 카테고리의 다른 글

[white-ship] 자바 스터디 후기  (0) 2021.04.13
14주차 과제: 제네릭  (0) 2021.02.27
13주차 과제: I/O  (0) 2021.02.20
12주차 과제: 애노테이션  (0) 2021.02.06
11주차 과제: Enum  (0) 2021.01.30

댓글