영권's

15주차 과제: 람다식 본문

스터디/백기선 라이브 스터디(자바)

15주차 과제: 람다식

ykkkk 2021. 6. 5. 14:46

목표

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

학습할 것 (필수)

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

 

람다식(Lambda expression)이란? 

람다식은 간단히 말해서 메서드를 하나의 '식(expression)'으로 표현한 것이다.

람다식은 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다.

메서드를 람다식으로 표현하면 메서드의 이름과 반환값이 없어지므로, 람다식을 '익명 함수(anonymous function)이라고도 한다.

예) 

int arr[] = new int[5];
Arrays.setAll(arr, (i) -> (int)(Math.random()*5)+1);

 

여기서 (i) -> (int)(Math.random()*5)+1) 이 바로 람다식이다.

이 람다식이 하는 일을 메서드로 표현하면 다음과 같다.

int method(){
	return (int)(Math.random() * 5 ) + 1;
}

위 메서드보다 람다식이 간결하면서도 이해하기 쉽다는 것에 이견이 없을 것이다.  

게다가 람다식은 메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수도 있다. 람다식으로 인해 메서드를 변수처럼 다루는 것이 가능해 진 것이다.

 

함수형 인터페이스

람다식의 형태는 매개 변수를 가진 코드 블록이기 때문에 마치 자바의 메서드를 선언하는 것처럼 보여진다.

자바는 메서드를 단독으로 선언할 수 없고 항상 클래스의 구성 멤버로 선언하기 때문에 람다식은 단순히 메서드를 선언하는 것이 아니라 이 메서드를 가지고 있는 객체를 생성해 낸다.

그럼 어떤 타입의 객체를 생성하는 것일까?

  • 인터페이스 변수 = 람다식;

람다식은 인터페이스 변수에 대입된다. 이 말은 람다식은 인터페이스의 익명 구현객체를 생성한다는 뜻이 된다. 인터페이스는 직접 객체화 할 수 없기 때문에 구현 클래스가 필요한데, 람다식은 익명구현 클래스를 생성하고 객체화한다.

람다식은 대입될 인터페이스의 종류에 따라 작성 방법이 달라지기 때문에 람다식이 대입될 인터페이스를 람다식의 타겟 타입(target type)이라고 한다.

 

함수적 인터페이스(@FinctionalInterface)

모든 인터페이스를 람다식의 타겟 타입으로 사용할 수는 없다.

람다식이 하나의 메서드를 정의하기 때문에 하나의 추상 메서드가 선언된 인터페이스만이 람다식의 타겟 타입이 될 수 있는데, 이러한 인터페이스를 함수적 인터페이스(functional interface)라고 한다.

함수적 인터페이스를 작성할 때 두개 이상의 추상 메서드가 선언되지 않도록 컴파일러가 체킹해주는 기능이 있는데, 인터페이스 선언 시 @FinctionalInterface 어노테이션을 사용하면 된다. 이 어노테이션은 두 개 이상의 추상 메서드가 선언되거나 추상 메서드가 없을 때 컴파일 오류를 발생시킨다.

 

추상메서드가 2개 이상일 때

@FinctionalInterface 어노테이션은 선택사항이며 없더라도 하나의 추상 메서드만 있다면 함수적 인터페이스이다 그러나 실수로 두 개 이상의 추상 메서드를 선언하는 것을 방지하기 위해 붙여주는 것이 좋다.

 

람다식 사용법

람다식은 '익명 함수' 답게 메서드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통{} 사이에 '->' 를 추가한다.

예를 들어 두 값 중에서 큰 값을 반환하는 메서드 max를 람다식으로 변환하면 아래와 같이 된다.

ex)

int max(int a, int b) {
	return a > b ? a : b;
}
//int max 
(int a, int b) -> {
	return a > b ? a : b;
}

 

람다식은 타겟 타입인 함수적 인터페이스가 가지고 있는 추상 메서드의 선언 형태에 따라 작성 방법이 달라진다.

 

다음과 같이 매개변수와 리턴 값이 없는 추상 메서드를 가진 함수적 인터페이스가 있다고 가정해보자.

 

package me.study.week15;

@FunctionalInterface
public interface MyFunctionalInterface {
    public void method();
}

람다식은 타겟 타입인 함수적 인터페이스가 가지고 있는 추상 메서드의 선언 형태에 따라서 작성 방법이 달라진다.

 

매개변수와 리턴값이 없는 람다식

 

매개변수와 리턴값이 없는 추상메서드는 다음과 같은 형태로 작성할 수 있다.

MyFunctionalInterface fi = () -> {/*실행문; ...*/}

람다식이 대입된 인터페이스의 참조 변수는 fi.method();와 같이 호출할 수 있다 method() 호출은 람다식의 중괄호를 실행시킨다.

 

매개변수가 있는 람다식

 

다음과 같이 매개변수가 있고 리턴값이 없는 추상메서드를 가진 함수적 인터페이스가 있다고 보자.

package me.study.week15;

@FunctionalInterface
public interface MyFunctionalInterface {
    public void method(int x);
}

이 인터페이스를 타겟 타입으로 갖는 람다식은 다음과 같은 형태로 작성해야 한다.

람다식에서 매개변수가 한 개인 이유는 mrthod()가 매개 변수를 하나만 가지기 때문이다.

MyFunctionalInterface fi = (x) -> {/* 실행문 */} // 또는 x -> { 실행문... }

람다식이 대입된 인터페이스 참조 변수는 fi.method(5); 와 같이 호출 할 수 있다. 매개 값으로 5를 주면 람다식의 x 변수에 5가 대입되고 x 는 증괄호{} 안에서 사용된다.

 

 

 

리턴값이 있는 람다식

package me.study.week15;

@FunctionalInterface
public interface MyFunctionalInterface {
    public int method(int x,int y);
}

 

다음과 같은 함수적 인터페이스가 있다고 보자.

 

이 인터페이스를 타겟 타입으로 갖는 람다식은 다음과 같은 형태로 작성해야 한다.

method()가 리턴 타입이 있기 때문에 중괄호 {} 에는 return문이 있어야 한다.

 

 MyFunctionalInterface fi = (x,y) -> {...; return 값;};

 

만약 중괄호{} 에 return 문만 있고, return문 뒤에 연산식이나 메서드 호출이 오는 경우라면 다음과 같이 작성할 수 있다.

 

생략 전 코드

MyFunctionalInterface fi = (x,y) -> { return x + y;};

생략 된 코드

MyFunctionalInterface fi = (x,y) -> x + y;

 

생략 전 코드

MyFunctionalInterface fi = (x,y) -> { return sum(x + y);};

생략 된 코드

MyFunctionalInterface fi = (x,y) -> sum(x,y);

 

 

Ex)

package me.study.week15;

@FunctionalInterface
public interface MyFunctionalInterface {
    public int method(int x, int y);
}
package me.study.week15;

public class MyFunctionalInterfaceExample {
    public static void main(String[] args) {
        MyFunctionalInterface fi;
        fi = (x,y) -> {
            int result = x + y;
            return result;
        };
        System.out.println(fi.method(2,5));

        fi = (x,y) -> {return x + y;};
        System.out.println(fi.method(2,5));

        fi = (x,y) -> x + y;
        System.out.println(fi.method(2,5));

        fi = (x,y) -> sum(x,y);
        System.out.println(fi.method(2,5));


    }

    private static int sum(int x,int y){
        return x+y;
    }
}

 

예제 코드를 작성하던 중 sum 메서드를 static이 아닌

 private int sum(int x,int y){
        return x+y;
 }

와 같이 인스턴스 메서드로 작성하면 컴파일 에러가 나는 것을 확인할 수 있었다.

 

인스턴스 메서드를 정적 메서드에서 참조할 수 없다고 나온다.

그래서 람다식이 어떻게 생겼는지 궁금해서 바이트 코드를 확인하게 되었다.

 

  • 람다식에서는 새로운 메서드를 static으로 생성해서 메서드를 실행시키는 것 같다.
  • 중간에 있는 INVOKEDYNAMIC에 의해 람다식이 사용되는것 같은데 무슨의미인지 몰라 찾아보다가 다른 블로그에서 작성한 글을 통해 알게 되었다.

JVM의 opcode

compile된 bytecode를 보면 네종류의 opcode를 사용하여 Java의 method를 표현합니다.

 

    • 1.7 이전
      • 생성자, private method, super call
      • invokestatic: static method 실행
      • invokevirtual: instance method 실행
      • invokeinterface: interface method 실행
      • invokespecial: exact한 함수 수행 - override 불가, 더이상 변화가 없는 함수들
    • invokedynamic
      • 동적 타입 언어를 위한 opcode
      • Jruby, Jython, Groovy 같은 JVM에서 돌아가는 동적 타입언어를 지원하기 위해 추가.
      • Java8 부터 default method, lambda compile시에 사용

 

Invokedynamic call

invokedynamic의 동작은 아래와 같은 동작으로 나뉩니다.

  • indy가 호출되면 bootstrap영역의 lambdafactory.metafactory()를 수행
    • 클래스 생성, 재사용, proxy, wrapper class, VM전용 API사용등등 성능 향상을 위한 최적화된 방법 사용
    • lambdafactory.metafactory(): java runtime library의 표준화 method
    • 어떤 방법으로 객체를 생성할지 dynamically 결정
  • java.lang.invoke.CallSite 객체를 return함.
    • 해당 lambda의 lambda factory
    • MethodHandle을 멤버변수로 가짐
    • 람다가 변환되는 함수 인터페이스의 인스턴스를 반환
    • 한번만 생성되고 재 호출시 재 사용함. 

참고) cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html

바이트코드는 생각보다 어려워서 정확한 이해는 하지 못했지만 결국 람다식으로 작성을 하게 되면 static 메서드로 되는것 같다.

Variable Capture

람다식에서 외부 지역변수를 참조하는 행위를 Lambda Capturing(람다 캡쳐링)이라고 한다.

람다에서 접근가능한 변수는 아래와 같이 세가지 종류가 있다.

  1. 지역 변수
  2. static 변수
  3. 인스턴스 변수

 

람다식의 실행 블록에는 클래스의 멤버(필드와 메서드) 및 로컬 변수를 사용할 수 있다. 클래스의 멤버는 제약 사항 없이 사용 가능하지만, 로컬 변수는 제약사항이 따른다.

클래스의 멤버 사용

람다식 실행 블록에는 클래스의 멤버인 필드와 메서드를 제약 사항 없이 사용할 수 있다. 하지만 this 키워드를 사용할 때에는 주의가 필요하다. 일반적으로 익명 객체 내부에서 this는 익명 객체의 참조이지만, 람다식에서 this는 내부적으로 생성되는 익명 객체의 참조가 아니라 람다식을 실행한 객체의 참조이다. 

 

다음 예제는 람다식에서 바깥 객체와 중첨 객체의 참조를 얻어 필드 값을 출력하는 방법을 보여주는데 중첩 객체 Inner에서 람다식을 실행했기 때문에 람다식 내부에서의 this는 중첩 객체 Inner이다.

 

package me.study.week15;

public class UsingThis {
    public int outterField = 10;

    class Inner{
        int innerfield = 20;

        void method(){
            MyFunctionalInterface fi = () -> {
                System.out.println("otterField " + outterField);
                System.out.println("otterField " + UsingThis.this.outterField + "\n");

                System.out.println("innerField " + innerfield);
                System.out.println("innerField " + this.innerfield + "\n");
            };
            fi.method();
        }
    }

    public static void main(String[] args) {
        UsingThis usingThis = new UsingThis();
        UsingThis.Inner inner = usingThis.new Inner();
        inner.method();
    }
}

 

 

클래스의 멤버 사용

람다식은 메서드 내부에서 주로 작성되기 때문에 로컬 익명 구현 객체를 생성시킨다고 봐야한다.

람다식에서 바깥 클래스의 필드나 메서드는 제한 없이 사용할 수 있으나, 메서드의 매개 변수 또는 로컬 변수를 사용하면 이 두 변수는 final 특성을 가져야 한다.

 

왜 final 특성을 가져야 하나?

로컬 클래스(메서드내 선언된 중첩 클래스)의 객체는 메서드 실행이 끝나도 힙 메모리에 존재해서 계속 사용될 수 있다. 매개 변수나 로컬 변수는 메서드 실행이 끝나면 스택 메모리에서 사라지기 때문에 로컬 객체에서 사용할 경우 문제가 발생한다.

자바는 이 문제를 해결하기 위해 컴파일 시 로컬 클래스에서 사용하는 매개 변수나 로컬 변수의 값을 로컬 클래스 내부에 복사해 두고 사용한다.

그리고 매개 변수나 로컬 변수가 수정되어 값이 변경되면 로컬 클래스에 복사해 둔 값과 달라지는 문제를 해결하기 위해 매개 변수나 로컬 변수를 final로 선언해서 수정을 막는다.

그런데 멀티 쓰레드 환경이라면, 여러 개의 쓰레드에서 람다식을 사용하면서 람다 캡쳐링이 계속 발생하는데 이 때 외부 변수 값의 불변성을 보장하지 못하면서 동기(sync)화 문제가 발생한다.

이러한 문제로 지역변수final, Effectively Final 제약조건을 갖게된다.

 

자바 7 이전까지는 final 키워드 없이 선언된 매개변수나 로컬 변수를 로컬 클래스에서 사용하면 컴파일 에러가 발생 했지만 자바8 이후 부터는 final 선언을 하지 않아도 여전히 값을 수정할 수 없는 final 특성을 갖는다.

final 키워드 존재 여부의 차이점은 로컬 클래스의 복사 위치이다.

final 키워드가 있으면 로컬 클래스의 메서드 내부에 지역 변수로 복사 되지만, final 키워드가 없으면 로컬 클래스의 필드로 복사된다.

참고) 이것이 자바다 pg.399

 

Effectively Final

람다식 내부에서 외부 지역변수를 참조하였을때 지역 변수는 재할당을 하지 않아야 하는 것을 의미한다.

 

따라서 매개 변수 또는 로컬 변수를 람다식에서 읽는 것은 허용되지만, 람다식 내부 또는 외부에서 변경 될 수 없다.

매개 변수 arg를 수정하려고 했을때 컴파일 에러가 발생하는 것을 볼 수 있다.

 

 

로컬 변수 사용 예제

package me.study.week15;

public class UsingLocalVariable {
    void method(int arg){ // arg는 final 특성을 가짐
        int localVar = 40; // localVar는 final 특성을 가짐

        //arg = 31; // final 특성 때문에 수정 불가
        // localVar = 41; // final 특성 때문에 수정 불가

        // 람다식
        MyFunctionalInterface fi = () -> {
            //로컬 변수 읽기
            System.out.println("arg " + arg);
            System.out.println("localVar " + localVar + "\n");
        };
        fi.method();
    }

    public static void main(String[] args) {
        UsingLocalVariable ulv = new UsingLocalVariable();
        ulv.method(20);
    }
}

결과

표준 API의 함수적 인터페이스

자바에서 제공되는 표준 API에서 한개의 추상 메서드를 가지는 인터페이스들은 모두 람다식을 이용해서 익명 구현 객체로 표현이 가능하다.

 

자바 8부터는 빈번하게 사용되는 함수적 인터페이스(Functional Interface)는 java.util.function 표준 API 패키지로 제공한다.

이 패키지에서 제공하는 함수적 인터페이스의 목적은 메서드 또는 생성자의 매개 타입으로 사용되어 람다식을 대입할 수 있도록 하기 위해서이다.

자바 8부터 추가되거나 변경된 API에서 이 함수적 인터페이스들을 매개 타입으로 사용할 수 있다.

java.util.function 패키지의 함수적 인터페이스는 크게 Consumer, Supplier, Function, Operator, Predicate로 구분 된다.

구분 기준은 추상 메서드의 매개 값과 리턴 값의 유무이다.

 

종류 추상 메서드 특징 메서드
Consumer - 매개값은 있고, 리턴값은 없음 void accept(T t)
Supplier - 매개값은 없고, 리턴값은 있음 T get()
Function - 매개값도 있고, 리턴값고 있음
- 주로 매개값을 리턴값으로 매핑(타입 변환)
R apply(T t)
Operator - 매개값도 있고, 리턴값고 있음
- 주로 매개값을 연산하고 결과를 리턴
T apply(T t)
Predicate - 매개값은 있고, 리턴값은 boolean
- 매개값을 조사해서 true/false값 리턴
boolean test(T t)

 

메소드, 생성자 레퍼런스

메서드 참조(Method References)는 말 그대로 메서드를 참조해서 매개 변수의 정보 및 리턴 타입을 알아내어, 람다식에서 불필요한 매개 변수를 제거하는 것이 목적이다.

람다식은 종종 기존 메서드를 단순히 호출만 하는 경우가 많다.

예를 들어 두 개의 값을 받아 큰 수를 리턴하는 Math 클래스의 max() 정적 메서드를 호출하는 람다식은 다음과 같다.

(left, right) -> Math.max(left, right);

 

람다식은 단순히 두 개의 값을 Math.max() 메서드의 매개값으로 전달하는 역할만 하기 때문에 다소 불편해 보인다.

이 경우 다음과 같이 메서드 참조를 이용하면 깔끔하게 처리할 수 있다.

Math::max; // 메서드 참조

 

메서드 참조도 람다식과 마찬가지로 인터페이스의 익명 구현 객체로 생성되므로 타겟 타입인 인터페이스의 추상 메서드가 어떤 매개 변수를 가지고, 리턴 타입이 무엇인가에 따라 달라진다.

IntBinaryOperator 인터페이스는 두 개의 int 매개값을 받아 int 값을 리턴하므로 Math::max 메서드 참조를 대입할 수 있다.

IntBinaryOperator operator = Math::max; // 메서드 참조

메서드 참조는 정적 또는 인스턴스 메서드를 참조할 수 있고, 생성자 참조도 가능하다.

 

 

정적 메서드와 인스턴스 메서드 참조

  • 정적(static) 메서드를 참조할 경우 클래스 이름 뒤에 :: 기호를 붙이고 정적 메서드 이름을 기술하면 된다.
    • 클래스::메서드
  • 인스턴스 메서드 경우에는 먼저 객체를 생성한 다음 참조 변수 뒤에 :: 기호를 붙이고 인스턴스 메서드 이름을 기술하면 된다.
    • 참조변수::메서드
package me.study.week15;

import java.util.function.IntBinaryOperator;

public class MethodReferencesExample {
    public static void main(String[] args) {
        IntBinaryOperator operator;

        // 정적 메서드 참조
        operator = (x,y) -> Calculator.staticMethod(x,y);
        System.out.println("결과1 : " + operator.applyAsInt(1,2));

        operator = Calculator::staticMethod;
        System.out.println("결과2 : " + operator.applyAsInt(3,4));

        // 인스턴스 메서드 참조
        Calculator obj = new Calculator();
        operator = (x,y) -> obj.instanceMethod(x,y);
        System.out.println("결과3 : " + operator.applyAsInt(5,6));

        operator = obj::instanceMethod;
        System.out.println("결과4 : " + operator.applyAsInt(7,8));
    }
}

 

매개 변수의 메서드 참조

메서드는 람다식 외부의 클래스 멤버일 수도 있고, 람다식에서 제공되는 매개 변수의 멤버일 수도 있다.

이전 예제는 람다식 외부의 클래스 멤버의 메서드를 호출하였지만 다음과 같이 람다식에서 제공되는 a 매개변수의 메서드를 호출해서 b 매개 변수를 매개값으로 사용하는 경우도 있다.

(a,b) -> {a.instanceMethod(b);}

 

이것을 메서드 참조로 표현하면 다음과 같다. a의 클래스 이름 뒤에 :: 기호를 붙이고 메서드 이름을 기술 하면 된다. 작성 방법은 정적 메서드 참조와 동일하지만, a의 인스턴스 메서드가 참조되므로 전혀 다른 코드가 실행된다.

클래스::instanceMethod

 

다음 예제는 두 문자열이 대소문자와 상관없이 동일한 알파벳으로 구성되어 있는지 비교한다.

비교를 위해 사용된 메서드는 String의 인스턴스 메서드인 compareToIgnoreCase()이다.

a.compareToIgnoreCase(b)로 호출될 때 사전 순으로 비교하여 int 값을 리턴한다.

사용된 함수적 인터페이스는 두 String 매개값을 받고 int 값을 리턴하는 ToIntBiFunction 이다.

 

package me.study.week15;

import java.util.function.ToIntBiFunction;

public class ArgumentMethodReferencesExample {
    public static void main(String[] args) {
        ToIntBiFunction<String,String> function;

        function = (a,b) -> a.compareToIgnoreCase(b);
        print(function.applyAsInt("Java8","JAVA8"));

        function = String::compareToIgnoreCase;
        print(function.applyAsInt("Java8","JAVA8"));
        
    }

    public static void print(int order){
        if (order < 0){
            System.out.println("사전순으로 먼저 옵니다.");
        }else if(order == 0){
            System.out.println("동일한 문자열입니다.");
        }else{
            System.out.println("사전순으로 나중에 옵니다.");
        }
    }
}

 

생성자 참조

메서드 참조(method references)는 생성자 참조도 포함한다. 생성자를 참조한다는 것은 객체 생성을 의미한다.

단순히 메서드 호출로 구성된 람다식을 메서드 참조로 대치할 수 있듯이, 단순히 객체를 생성하고 리턴하도록 구성된 람다식은 생성자 참조로 대치할 수 있다.

다음 코드를 보면 람다식은 단순히 객체 생성 후 리턴만 한다.

(a,b) -> {return new 클래스(a,b);}

 

이 경우, 생성자 참조로 표현하면 다음과 같다. 클래스 이름 뒤에 :: 기호를 붙이고 new 연산자를 기술하면 된다. 생성자가 오버로딩되어 여러 개가 있을경우, 컴파일러는 함수적 인터페이스의 추성 메서드와 동일한 매개 변수 타입과 개수를 가지고 있는 생성자를 찾아 실행한다.

만약 해당 생성자가 존재하지 않으면 컴파일 오류가 발생한다.

클래스::new

 

 

다음 예제는 생성자 참조를 이용해서 두 가지 방법으로 Member 객체를 생성한다. 하나는 Function<String,Member> 함수적 인터페이스의 Member apply(String) 메서드를 이용해서 Member 객체를 생성하였고, 다른 하나는 BiFunction<String, String, Member> 함수적 인터페이스의 Member apply(String, String) 메서드를 이용해서 Member 객체를 생성하였다.

생성자 참조는 두 가지 방법 모두 동일하지만, 실행되는 Member 생성자가 다름을 볼 수 있다.

package me.study.week15;

public class Member {
    private String name;
    private String id;

    public Member() {
        System.out.println("Member() 실행");
    }

    public Member(String id) {
        System.out.println("Member(String id) 실행");
        this.id = id;
    }

    public Member(String name, String id) {
        System.out.println("Member(String name, String id) 실행");
        this.name = name;
        this.id = id;
    }
}
package me.study.week15;

import java.util.function.BiFunction;
import java.util.function.Function;

public class ConstructorReferencesExample {
    public static void main(String[] args) {
        Function<String, Member> function1 = Member::new; // 생성자 참조
        Member member1 = function1.apply("angel1"); // 매개값 1개

        BiFunction<String,String,Member> function2 = Member::new; // 생성자 참조
        Member member2 = function2.apply("천사", "angel"); // 매개값 2개
    }
}

결과

출처:

 

이것이 자바다.

 

자바의 정석 3판

 

watrv41.gitbook.io/devbook/java/java-live-study/15_week

 

cr.openjdk.java.net/~briangoetz/lambda/lambda-translation.html

 

https://tourspace.tistory.com/12 [투덜이의 리얼 블로그]

 

sujl95.tistory.com/76

 

 

'스터디 > 백기선 라이브 스터디(자바)' 카테고리의 다른 글

14주차 과제: 제네릭  (0) 2021.06.01
13주차 과제: I/O  (0) 2021.05.31
12주차 과제: 애노테이션  (0) 2021.03.05
11주차 과제: Enum  (0) 2021.03.05
10주차 과제: 멀티쓰레드 프로그래밍  (0) 2021.02.25
Comments