Language/Java

[자바 스터디 15주차] 람다식

소프 2021. 3. 5.

목표

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

학습할 것

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

람다식이란?

람다식은 메소드를 하나의 '식(expression)'으로 표현한 것이다.

int[] arr = new int[5];
Arrays.setAll(arr, i -> (int)(Math.random() * 5) + 1);
  • 위의 문장에서 '() -> (int)(Math.random() * 5) + 1'이 바로 람다식이다.
  • 메소드를 람다식으로 표현하면 메소드의 이름과 반환값이 없으므로, 람다식을 '익명 함수(anonymous function)'이라고도 한다.

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

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

메서드 형태보다 람다식이 간결하면서도 이해하기 쉽다.

게다가 모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야 하고, 객체도 생성해야 이 메소드를 호출할 수 있지만, 람다식은 이 과정 없이 오직 람다식 자체만으로 이 메서드의 역할을 수행할 수 있는 것이 큰 장점이다.

 

Q. 메서드와 함수의 차이

전통적으로 프로그래밍에서 함수라는 이름은 수학에서 따온 것이다.
수학의 함수와 개념이 유사하기 때문이다.
그러나 객체지향개념에서는 함수(function) 대신 객체의 행위나 동작을 의미하는 메서드(method)라는 용어를 사용한다.

메서드는 함수와 같은 의미이지만, 특정 클래스에 반드시 속해야 한다는 제약이 있기 때문에 기존의 함수와 같은 의미를 다른 용어를 선택해서 사용한 것이다.

그러나 이제 다시 람다식을 통해 메서드가 하나의 독립적인 기능을 하기 때문에 함수라는 용어를 사용하게 되었다.

 

람다식 작성

// 메소드
반환타입 메소드명(매개변수 선언) {
	문장
}

// 람다식
(매개변수 선언) -> {
	문장
}
  • 메소드에서 이름과 반환타입을 제거하고 매개변수 선언부와 몸통{} 사이에 '->' 화살표를 추가한다.

case 1.

// 메소드
int max(int a, int b){
	return a > b ? a : b
}

// 람다식
(int a, int b) -> {
	return a > b ? a : b
}

 

case 2.

(int a, int b) -> { a > b ? a : b}
  • 반환값이 있는 메소드의 경우 return 문 대신 '식'으로 대신할 수 있다.
  • 문장이 아닌 '식'이므로 끝에 세미콜론(;)을 붙이지 않는다.

case 3.

(a, b) -> { a > b ? a : b }
  • 매개변수의 타입은 추론이 가능한 경우 생략할 수 있는데, 대부분의 경우 생략이 가능하다.

case 4.

(a) -> a * a
(int a) -> a * a

a -> a * a     //OK.
int a -> a * a //ERROR.
  • 매개변수가 하나일 때는 괄호()를 생략할 수 있다.
  • 단, 매개변수가 타입이 있으면 괄호()를 생략할 수 없다.

case 5.

(name, i) -> {
	System.out.println(name + "=" + i);
}

(name, i) -> System.out.println(name + "=" + i)
  • 괄호 { } 의 문장이 하나일 때는 괄호 { } 를 생략할 수 있다.
  • 괄호 { } 의 문장이 return문일 경우 괄호 { }를 생략할 수 없다.

 

함수형 인터페이스

// 1
(int a, int b) -> a > b ? a : b

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

람다식은 메소드와 동등한 것처럼 보이지만, 사실 람다식은 익명 클래스의 객체와 동등하다.

 

위의 익명 객체의 메소드를 어떻게 호출할 수 있을까?

참조변수가 있어야 객체의 메소드를 호출할 수 있으니 일단 익명 객체의 주소를 f라는 참조변수에 저장하자.

 

타입 f = (int a, int b) -> a > b ? a : b //참조변수의 타입은?

참조변수 f의 타입은 어떤 것이어야 할까? 참조형이므로 클래스 또는 인터페이스가 가능하다.

그리고 람다식과 동등한 메소드가 정의되어 있는 것이어야 한다. 그래야 참조변수로 익명 객체(람다식)의 메소드를 호출할 수 있기 때문이다.

 

예를 들어 max()라는 메소드가 정의된 MyFunction 인터페이스가 있다고 가정하자.

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;
    }
};

int big = f.max(5, 3) // 익명 객체의 메소드 호출
  • MyFunction 인터페이스에 정의된 메소드 max()는 람다식 '(a, b) -> a > b ? a : b'와 메소드 선언부가 일치한다.

따라서, 위 코드의 익명 객체를 아래와 같이 람다식으로 변경할 수 있다.

MyFunction f = (a, b) -> a > b ? a : b; //익명 객체를 람다식으로 대체
int big = f.max(5, 3); //익명 객체 메소드 호출
👉 익명 객체를 람다식으로 대체 가능한 이유?
람다식도 실제로는 익명 객체이고, MyFunction 인터페이스를 구현한 익명 객체의 메소드 max()와 람다식의 매개변수 타입과 개수, 그리고 반환값이 일치하기 때문이다.

위에서 봤듯이 하나의 메소드가 선언된 인터페이스를 정의해서 람다식을 다루는 것은 기존의 자바 규칙을 어기지 않으면서도 자연스럽다.

 

그래서 인터페이스를 통해 람다식을 다루기로 결정됐고, 람다식을 다루기 위한 인터페이스를 함수형 인터페이스라고 부르기로 했다.

@FunctionalInterface
interface MyFunction { //함수형 인터페이스 MyFunction을 의미
	public abstract int max(int a, int b);
}
  • 함수형 인터페이스에는 오직 하나의 추상 메소드만 존재해야 한다.
    • 그래야만, 람다식과 인터페이스의 메소드가 1:1로 연결될 수 있다.
  • static 메소드와 default메소드의 개수에는 제약이 없다.
💡 @FunctionalInterface를 붙이면, 컴파일러가 함수형 인터페이스를 올바르게 정의하였는지 확인하므로, 붙이는 습관을 들이자.

 

인프런 The Java 8. 강의

 

힘수형 인터페이스(Functional Interface)

  • 추상 메소드를 딱 하나만 가지고 있는 인터페이스
  • SAM(Single Abstract Method) 인터페이스
  • @FunctionalInterface 애노테이션을 가지고 있는 인터페이스

 

람다 표현식

  • 함수형 인터페이스의 인스턴스를 만드는 방법으로 쓰일 수 있다.
  • 코드를 줄일 수 있다.
  • 메소드 매개변수, 리턴타입, 변수로 만들어 사용할 수 있다.

자바에서 함수형 프로그래밍

  • 함수를 First Class Object로 사용할 수 있다.
  • 순수 함수(Plus Function)
    • 사이드 이팩트 만들 수 없다.(함수 밖에 있는 값을 변경하지 못한다.)
    • 상태가 없다. (함수 밖에 정의되어 있는)
  • 고차 함수(High-Order Function)
    • 함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수 있다.

매개변수와 반환타입

@FunctionalInterface
interface MyFunction {
	void myMethod(); //추상 메소드
}

메소드의 매개변수가 MyFunction 타입이면, 이 메소드를 호출할 때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 뜻이다.

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

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

또는, 참조변수 없이 아래와 같이 직접 람다식을 매개변수로 지정할 수도 있다.

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

Example

public enum Operator {
	PLUS("+", (a,b) -> a+b),
    SUB("-", (a,b) -> a-b),
    DIV("/", (a,b) -> a/b),
    MUL("*", (a,b) -> a*b);
    
    private final String operator;
    
    private final OperatorInterface operations;
    
    Operator(String operator, OperatorInterface operations){
    	this.operator = operator;
        this.operations = operations;
    }
    ...
    public int apply(int a, int b){
    	return this.operations.apply(a,b);
    }
}

...

@FunctionalInterface
public interface OperatorInterface{
	int apply(int a, int b);
}

테스트코드

public class CalculatorTest{
	
    @Parameterized //하나의 테스트 메소드로 여러 개의 파라미터에 대해서 테스트 가능.
    @CsvSource(value = "{"1:2:3","2:5:7"}, delimiter = ":") //1+2=3, 2+5=7
    void plusTest(int a, int b, int result){
    	Operator operator = Operator.PLUS;
        assertThat(operator.apply(a,b)).isEqualTo(result);
    }
    
    ...
}

출처 : github.com/next-step/java-racingcar/pull/1926

 

메소드의 반환타입이 함수형 인터페이스라면, 그 함수형 인터페이스의 추상메소드와 동등한 람다식을 가리키는 참조변수를 반환하거나, 람다식을 직접 반환 할 수 있다.

MyFunction myMethod(){
	MyFunction f = () -> {};
    return f; // 두 줄을 한 줄로 줄이면, return () -> {};
}

다음은 위에서 설명한 것들을 사용한 함수형 인터페이스와 람다식 예제이다.

@FunctionalInterface
interface MyFunction{
	void run(); //public abstract void run();
}	

public class LambdaEx1 {
	static void execute(MyFunction f){
    	f.run();
    }
    
    static MyFunction getMyFunction(){
    	return () -> System.out.println("f3.run()");
    }
    
    public static void main(String[] args){
    	MyFunction f1 = () -> System.out.println("f1.run()");
        
        MyFunction f2 = new MyFunction(){
        	@Override
            public void run(){
            	System.out.println("f2.run()");
            }
        };
        
        MyFunction f3 = getMyFunction();
        
        f1.run();
        f2.run();
        f3.run();
        
        execute(f1);
        execute( () -> System.out.printlnt("run()"));
    }
}
// 결과
f1.run()
f2.run()
f3.run()
f1.run()
run()

람다식 타입과 형변환

람다식은 익명 객체이고, 익명 객체는 타입이 없다.

따라서, 대입 연산자의 양변의 타입을 일치시키기 위해 형변환이 필요하다.

MyFunction f = (MyFunction)(() -> {});
  • 위의 람다식은 MyFunction 인터페이스를 직접 구현하지 않았지만, 인터페이스를 구현한 클래스의 객체와 동일하기 때문에, 위와 같은 형변환을 허용한다.
    • 그리고 이 형변환은 생략가능하다.
Object obj = (Object) ( () -> {}) //ERROR 함수형 인터페이스로만 형변환 가능
  • Object타입으로는 형변환 할 수 없다.
  • 오직 함수형 인터페이스로만 형변환 가능하다.

굳이 Object 타입으로 형변환하려면, 먼저 함수형 인터페이스로 변환해야 한다.

Object obj = (Object)(MyFunction)( () -> {});
String str = ( (Object)(MyFunction)( () -> {})).toString();

 

Variable Capture

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

 

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

  • 지역 변수
  • static 변수
  • 인스턴스 변수

지역변수만 변경이 불가능하고 나머지 변수들은 읽기 및 쓰기가 가능하다.

람다는 지역 변수가 존재하는 스택에 직접 접근하지 않고, 지역 변수를 자신(람다가 동작하는 쓰레드)의 스택에 복사한다.

각각의 쓰레드마다 고유한 스택을 갖고 있어서 지역 변수가 존재하는 쓰레드가 사라져도 람다는 복사된 값을 참조하면서 에러가 발생하지 않는다.

 

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

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

 

익명 클래스 구현체와 달리 "쉐도잉" 하지 않는다.

익명 클래스는 새로 스콥을 만들지만, 람다는 람다를 감싸고 있는 스콥과 같다.

💡 Effectively Final
람다식 내부에서 외부 지역변수를 참조하였을 때 지역 변수는 재할당을 하지 않아야 하는 것을 의미한다.
@FunctionalInterface
interface MyFunction3 {
    void myMethod();
}

class Outer{
	int val = 10;
    
    class Inner{
    	int val = 20;
        
        void method(int i) //void method(final int i)
        	int val = 30; //final int val = 30;
            i = 10; //ERROR. 상수의 값은 변경할 수 없다.
            
            MyFunction3 f = () -> {
        		System.out.println("             i : " + i);
                System.out.println("           val : " + val);
                System.out.println("      this.val : " + ++this.val);
                System.out.println("Outer.this.val : " + ++Outer.this.val);
            }
            
            f.myMethod();
            
    } // End Of Inner
} // End Of Outer

public class LambdaEx3 {
    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.method(100);
    }
}
// 결과
             i : 100
           val : 30
      this.val : 21
Outer.this.val : 11

 

  • 람다식 내에서 참조하는 지역변수는 final이 붙지 않아도 상수로 간주된다.
  • 람다식 내에서 지역변수 i와 val을 참조하고 있으므로 람다식 내에서나 다른 곳에서도 이 변수들의 값을 변경할 수 없다.
  • 반면, Inner 클래스와 Outer 클래스의 인스턴스 변수인 this.val과 Outer.this.val은 상수로 간주되지 않아서 값을 변경해도 된다.
  • 람다식에서 this는 내부적으로 생성되는 익명 객체의 참조가 아니라 람다식을 실행한 객체의 참조다.
  • 따라서, 위의 코드에서 this는 중첩 객체인 Inner이다.

 

예시 2

-> run() 메소드의 int baseNumber는 IntConsumer 람다에서 참조되고 있다.

package me.ssonsh.java8to11;

import java.util.function.Consumer;
import java.util.function.IntConsumer;

public class Foo {

    public static void main(String[] args){
        Foo foo = new Foo();
        foo.run();
    }

    private void run() {
        // 참조는 할 수 있다.
        // java8부터는 final 키워드를 생략할 수 있는 케이스가 있다.
        // 이 변수가 사실상 final인 경우이다. (어디서도 이 변수를 변경하지 않는 경우)
        int baseNumber = 10;
        
        //1. 로컬 클래스
        class LocalClass{
        	void PrintBaseNumber(){
            	System.out.println(baseNumber);
            }
        }
        
        //2. 익명 클래스
        Consumer<Integer> integerConsumer = new Consumer<Integer>(){
        	@Override
            public void accept(Integer integer){
            	System.out.println(baseNumber);
            }
        };
        
        //3. 람다
        IntConsumer printInt = (i) -> {
        	System.out.println(i + baseNumber);
        };
        
        printInt.accept(1);
    }
}

로컬 클래스와 익명 클래스 <> 람다와 다른점

-> 쉐도잉(가려지는 것, 덮어지는 것)

  • 로컬 클래스와 익명 클래스는 메소드 내에서 새로운 Scope이다. -> 쉐도잉 발생
int baseNumber = 10;

// 1. 로컬 클래스
class LocalClass{
    void PrintBaseNumber(){
        int baseNumber = 11;
        // baseNumber 값은 11이 찍힐 것이다. (scope)
        // run 메소드에서 선언한 baseNumber에 대해 쉐도잉이 발생
        System.out.println(baseNumber);
    }
}

// 2. 익명 클래스
Consumer<Integer> integerConsumer = new Consumer<Integer>() {
    @Override
    public void accept(Integer baseNumber) {
        // 파라미터로 전달받은 baseNumber 가 찍힐 것이다.
        // run 메소드에서 선언한 baseNumber에 대해 쉐도잉이 발생
        System.out.println(baseNumber);
    }
};

 

  • 람다는 람다를 감싸고 있는 메소드와 같은 scope이다. -> 같은 이름의 변수를 선언할 수 없다.
람다에 들어있는 변수와 람다가 사용되고 있는 클래스의 변수들은  같은 Scope이다.
int baseNumber = 10;

// 3. 람다
IntConsumer printInt = (baseNumber) -> {
	System.out.println(baseNumber);
};

위와 같이 선언하게 되는 경우 에러 발생

-> Variable 'baseNumber' is already defined in the scope

https://www.notion.so/a875fcd046db4ddd8dce01bf61743f5e

  • 좌측 익명클래스의 노란색 배경이 전체를 다 덮었다면 쉐도잉을 더욱 더 잘 표현하는 그림이지 않았을까? 라는 기선님의 feedback!

Plus. Variable Capture의 자세한 설명 추가

(참고. blog.naver.com/hsm622/222260183401)

 

람다식의 실행 코드 블록 내에서 클래스의 멤버 필드와 멤버 메소드, 그리고 지역 변수를 사용할 수 있다.

클래스의 멤버 필드와 멤버 메소드는 특별한 제약없이 사용 가능하지만, 지역변수를 사용함에 있어 제약이 존재한다.

  • 이 내용을 이해하기 위해서는 jvm 메모리에 대해 알아야 한다.

잠시 람다식이 아닌 다른 애기를 해보자.

멤버 메소드 내부에서 클래스의 객체를 생성해서 사용할 경우 다음과 같은 문제가 있다.

익명 구현 객체를 포함해서 객체를 생성할 경우 new라는 키워드를 사용한다.

 

new라는 키워드를 사용한다는 것은 동적 메모리 할당 영역(이하 heap)에 객체를 생성한다는 의미이다.

 

이렇게 생성된 객체는 자신을 감싸고 있는 멤버 메소드의 실행이 끝난 이후에도 heap 영역에 존재하므로 사용할 수 있지만, 이 멤버 메소드에 정의된 매개변수나 지역 변수는 런타임 스택 영역(이하 stack)에 할당되어 메소드 실행이 끝나면 해당 영역에서 사라져 더 이상 사용할 수 없게 된다.

 

그렇기 때문에 멤버 메소드 내부에서 생성된 객체가 자신을 감싸고 있는 메소드의 매개변수나 지역변수를 사용하려 할 때 문제가 생길 수 있다.

 

1. 클래스의 멤버 메소드의 매개변수와 이 메소드 실행 블록 내부의 지역변수는 JVM의 STACK에 생성되고 실행이 끝나면 STACK에서 사라진다.

2. new 연산자를 사용해서 생성한 객체는 JVM의 HEAP영역에 객체가 생성되고 GC(Garbage Collector)에 의해 관리되며, 더 이상 사용하지 않는 객체에 대해 필요한 경우 메모리에서 제거한다.

 

heap에 생성된 객체가 stack의 변수를 사용하려고 하는데, 사용하는 시점에 stack에 더 이상 해당 변수가 존재하지 않을 수 있다는 것이다.

왜냐하면 stack은 메소드 실행이 끝나면 매개변수나 지역변수에 대해 제거하기 때문이다.

그래서 더 이상 존재하지 않는 변수를 사용하려 할 수 있기 때문에 오류가 발생한다.

 

자바는 이 문제를 Variable Capture라고 하는 값 복사를 사용해서 해결한다.

즉, 컴파일 시점에 멤버 메소드의 매개변수나 지역변수를 멤버 메소드 내부에서 생성한 객체가 사용할 경우, 객체 내부로 값을 복사해서 사용한다.

  • 하지만 모든 값을 복사해서 사용할 수 있는 것은 아니다.

 

여기에도 제약이 존재하는데, final 키워드로 작성되었거나 final 성격을 가져야 한다.

final 키워드는 알겠는데 final 성격을 가져야한다는 것은 왜 그럴까?

-> final 성격을 가진다는 것은  final 키워드로 선언된 것은 아니지만 값이 한번만 할당되어 final처럼 쓰이는 것을 뜻한다.

 

복잡한 내용과 예제가 존재하지만, 쉽게 생각한다면 익명 구현 객체를 사용할 때와 람다식을 사용했을 때 다음과 같은 차이점이 있다는 것만이라도 기억해보자.

1. 람다식은 익명 구현 객체처럼 별도의 객체를 생성하거나 컴파일 결과 별도의 클래스를 생성하지 않는 다는 것이다.

2. 람다식 내부에서 사용하는 변수는 Variable Capture가 발생하며, 이 값은 final이거나 final처럼 사용해야 한다는 것이다.

 

java.util.function 패키지

 

java.util.function 패키지에 일반적으로 자주 쓰이는 형식의 메소드를 함수형 인터페이스로 미리 정의해놨다.

매번 새로운 함수형 인터페이스를 정의하지 않고, 가능하면 이 패키지의 함수형 인터페이스를 사용하자.

 

기본적인 함수형 인터페이스

함수형 인터페이스 메소드 설명
java.lang.Runnable void run() 매개변수도 없고, 반환값도 없음
Supplier<T> T get() 매개변수는 없고, 반환값만 있음
Consumer<T> void accept(T t) Supplier와 반대로 매개변수만 있고, 반환값 없음
Function<T, R> R apply(T t) 일반적 함수. 하나의 매개변수를 받아 결과값 리턴
Predicate<T> boolean test(T t) 조건식을 표현. 매개변수는 하나, 반환 타입 boolean
  • 매개변수와 반환값 유무에 따라 4개의 함수형 인터페이스가 정의되어 있다.
  • Function의 변형으로 Predicate가 있는데, 반환값이 boolean인 것만 제외하면 Function과 동일하다.

Plus. 자바 API에서 제공해주는 함수형 인터페이스의 별명

 

Suppliers

  • 별명 : 게으른 공급자.
  • 이유 : 입력값이 존재하지 않는데, 내가 원하는 것을 미리 준비하기 때문

Consumer

  • 별명 : Spartan(스파르탄!!)
  • 모든 것을 빼앗고 아무것도 내주지 마라!

Function

  • 별명 : 트랜스포머(변신로봇)
  • 이유 : 값을 변환하기 때문에

Predicate

  • 별명 : 판사
  • 이유 : 참 거짓으로 판단하기 때문

 

매개변수가 두 개인 함수형 인터페이스

함수형 인터페이스 메소드 설명
BiConsumer<T, U> void accept(T t, U u) 두 개의 매개변수, 반환값은 없음.
BiFunction<T, U, R> R apply(T t, U u) 두 개의 매개변수를 받아 결과를 반환.
BiPredicate<T> boolean test(T t, U u) 조건식을 표현. 매개변수는 둘, 반환 타입 boolean

 

UnaryOperator, BinaryOperator

함수형 인터페이스 메소드 설명
UnaryOperator<T> T apply(T t) Function의 자손. Function과 달리 매개변수와 결과타입 같음.
BinaryOperator<T> T apply(T t, T t) BiFunction의 자손. BiFunction과 달리 매개변수와 결과타입 같음.
  • 매개변수의 타입과 반환타입의 타입이 모두 일치한다는 점을 제외하고는 Function과 같다.

예제

public class LambdaEx5 {
	public static void main(String[] args){
    	Supplier<Integer> s = () -> (int)(Math.random() * 100) + 1;
        Consumer<Integer> c = i -> System.out.print(i + ", ");
        Predicate<Integer> p = i -> i % 2 == 0;
        Function<Integer, Integer> f = i -> i / 10 * 10;
        
        List<Integer> list = new ArrayList<>();
        makeRandomList(s, list);
        System.out.println(list);
        printEvenNum(p, c, list);
        List<Integer> newList = doSomething(f, list);
        System.out.println(newList);
    }
    
    static <T> List<T> doSomething(Function<T, T> f, List<T> list){
    	List<T> newList = new ArrayList<>(list.size());
        
        for(T i : list){
        	newList.add(f.apply(i));
        }
        
        return newList;
    }
    
    static <T> void printEvenNum(Predicate<T> p, Consumer<T> c, List<T> list){
  		System.out.print("[");
        for(T i : list){
        	if(p.test(i)){
            	c.accpet(i);
            }
        }
        System.out.println("]");
    }
    
    static <T> void makeRandomList(Supplier<T> s, List<T> list){
    	for(int i = 0; i < 10; i++){
        	list.add(s.get());
        }
    }
}
// 결과
[69, 56, 56, 98, 26, 54, 10, 7, 19, 54]
[56, 56, 98, 26, 54, 10, 54, ]
[60, 50, 50, 90, 20, 50, 10, 0, 10, 50]

 

 

기본형을 사용하는 함수형 인터페이스

위에서 언급한 함수형 인터페이스는 매개변수와 반환값이 모두 제네릭 타입이었다.

그래서, 기본형 타입의 값을 처리할 때도 래퍼(wrapper) 클래스를 사용했는데, 이것은 당연히 비효율적이다.

보다 효율적인 처리를 위해 기본형을 사용하는 함수형 인터페이스도 제공한다.

 

함수형 인터페이스 메소드 설명
DoubleToIntFunction int applyAsInt(doubled) AToBFunction은 입력이 A 타입 출력이 B타입
ToIntFunction<T> int applyAsInt(T value) ToBFunction은 출력이 B 타입, 입력이 제네릭 타입
IntFunction<R> R apply(T t, U u) AFunction은 입력이 A타입, 출력은 제네릭
ObjIntConsumer<T> void accpet(T t, U u) ObjAFunction은 입력이 T, A타입이고 출력은 없음.
public class LambdaEx6 {
    public static void main(String[] args) {
    	IntSupplier s = () -> (int)(Math.random() * 100) + 1;
        IntConsumer c = i -> System.out.println(i + ", ");
        IntPredicate p = i -> i % 2 == 0;
        IntUnaryOperator op = i -> i / 10 * 10;
        
        int[] arr = new int[10];
        
        makeRandomList(s, arr);
        System.out.println(Arrays.toString(arr));
        printEvenNum(p, c, arr);
        int[] newArr = doSomething(op, arr);
        System.out.println(Arrays.toString(newArr));
    
        static int[] doSomething(IntUnaryOperator op, int[] arr) {

          int[] newArr = new int[arr.length];

          for (int i = 0; i < newArr.length; i++) {
              newArr[i] = op.applyAsInt(arr[i]);  //  apply()가 아니라 applyAsInt()
          }

          return newArr;
    	}

        static void printEvenNum(IntPredicate p, IntConsumer c, int[] arr) {
            System.out.print("[");
            for (int i : arr) {
                if (p.test(i)) {
                    c.accept(i);
                }
            }
            System.out.println("]");
        }

        static void makeRandomList(IntSupplier s, int[] arr) {
            for (int i = 0; i < arr.length; i++) {
                arr[i] = s.getAsInt();  // get()이 아니라 getAsInt()
            }
        }

}
// 결과
[88, 63, 35, 56, 59, 64, 35, 16, 75, 15]
[88, 56, 64, 16, ]
[80, 60, 30, 50, 50, 60, 30, 10, 70, 10]

 

  • IntUnaryOperator가 Function이나 IntFunction보다 오토방식&언박싱의 횟수가 줄어들어 성능상 이점이 있다.
  • 매개변수의 타입과 반환타입이 일치할 때는 앞서 배운 Function 대신 UnaryOperator를 사용하자.

 

Function의 합성과 Predicate 결합

 

java.util.function 패키지의 함수형 인터페이스에 추상메소드 외에 디폴트 메소드와 static 메소드가 정의되어 있다.

 

다른 함수형 인터페이스도 비슷한 메소드가 정의되어있기 때문에 Function과 Predicate만 살펴보자.

 

Function의 합성

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }

    static <T> Function<T, T> identity() {
        return t -> t;
    }
}
  • andThen()
    • f.andThen(g) : 함수 f를 먼저 적용하고 그 다음 함수 g를 적용
package me.ssonsh.java8to11;

import java.util.function.Function;

public class Foo {

    public static void main(String[] args){
        Function<Integer, Integer> plus10 = (number) -> number + 10;
        Function<Integer, Integer> multiply2 = (number) -> number * 2;

        System.out.println(plus10.apply(1));
        System.out.println(multiply2.apply(1));

        Function<Integer, Integer> plus10AndMultiply2 = plus10.andThen(multiply2);
        System.out.println(plus10AndMultiply2.apply(2));

    }
}
  • compose()
    • f.compose(g) : andThen()과 반대, 함수 g를 먼저 적용하고 그 다음 f를 적용
    • 결과값을 다시 입력값으로 사용하는 것
package me.ssonsh.java8to11;

import java.util.function.Function;

public class Foo {

    public static void main(String[] args){
        Function<Integer, Integer> plus10 = (number) -> number + 10;
        Function<Integer, Integer> multiply2 = (number) -> number * 2;
        
        System.out.println(plus10.apply(1));
        System.out.println(multiply2.apply(1));
        
        Function<Integer, Integer> multiply2AndPlus10 = plus10.compose(multiply2);
        System.out.println(multiply2AndPlus10.apply(2));
    }
}
  • identify()
    • 이전과 이후가 동일한  '항등함수'가 필요할 때 사용
    • x -> x, Function.identity() 두 개가 동일
    • 항등 함수는 잘 사용되지 않는 편이며, map()으로 변환작업할 때, 변환없이 그대로 처리하고자 할 때 사용

Predicate의 결합

@FunctionalInterface
public interface Predicate<T> {

    boolean test(T t);

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }

}

여러 조건식들을 논리 연산자인 &&(and), ||(or), !(not)으로 연결해서 하나의 식을 구성할 수 있는 것처럼,

여러 Predicateand(), or(), negate()로 연결해서 하나의 새로운 Predicate로 결합할 수 있다.

 

Predicate<Integer> p = i -> i < 100;
Predicate<Integer> q = i -> i < 200;
Predicate<Integer> r = i -> i % 2 == 0;
Predicate<Integer> notP = p.negate(); // i >= 100

Predicate<Integer> all = notP.and(q.or(r));

static메소드인 isEqual()은 두 대상을 비교하는 Predicate를 만들 때 사용한다.

Predicate<String> p = Predicate.isEqual(str1);
boolean result = p.test(str2); //str1과 str2가 같은지 비교하여 결과 반환

boolean result = Predicate.isEqual(str1).test(str2); //한 줄로 변경

예제

public class LambdaEx7 {
    public static void main(String[] args) {
        Function<String, Integer> f = s -> Integer.parseInt(s, 16);
        Function<Integer, String> g = i -> Integer.toBinaryString(i);

        Function<String, String > h = f.andThen(g);
        Function<Integer, Integer> h2 = f.compose(g);   //  g.andThen(f);

        System.out.println(h.apply("FF"));  // "FF" -> 255 -> "11111111"
        System.out.println(h2.apply(2));    // 2 -> "10" -> 16

        Function<String, String> f2 = x -> x;   //  항등함수(identity function)
        System.out.println(f2.apply("AAA"));    // AAA 동일하게 출력

        Predicate<Integer> p = i -> i < 100;
        Predicate<Integer> q = i -> i < 200;
        Predicate<Integer> r = i -> i % 2 == 0;
        Predicate<Integer> notP = p.negate();   //  i >= 100

        Predicate<Integer> all = notP.and(q.or(r));

        System.out.println(all.test(150));

        String str1 = "hello";
        String str2 = "hello";

        Predicate<String> p2 = Predicate.isEqual(str1);
        boolean result = p2.test(str2);
        System.out.println(result);

    }
}
// 결과
11111111
16
AAA
true
true

 

메소드, 생성자 레퍼런스

 

메소드 참조

람다식이 하나의 메소드만 호출하는 경우에는 '메소드 참조(method reference)'를 통해 람다식을 간략하게 작성할 수 있다.

 

메소드 참조를 작성하는 방법은 아래처럼 간단하다.

 

클래스이름::메소드이름
or
참조변수::메소드이름
Function<String, Integer> f = s -> Integer.parseInt(s);

보통 위처럼 람다식을 작성하는데, 이 람다식을 메소드로 표현하면 아래와 같다.

Integer wrapper(String s){ // 메소드 이름 의미없음. 테스트용
	return Integer.parseInt(s);
}

이 wrapper 메소드는 별로 하는 일이 없다. 값을 받아서 Integer.parseInt()에게 넘겨줄 뿐, 차라리 Integer.parseInt()를 직접 호출하는게 좋지 않을까?

Function<String, Integer> f = s -> Integer.parseInt(s);

// 메소드 참조
Function<String, Integer> f = Integer::parseInt;

메소드 참조를 이용하면 더 간략하게 작성할 수 있다.

위 메솓 참조에서 람다식 일부가 생략되었지만, 컴파일러는 생략된 부분을 우변의 parseInt메소드의 선언부로부터, 또는 좌변의 Function 인터페이스에 지정된 제네릭 타입으로부터 쉽게 알아낸다.

 

또 다른 예시

BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);

위 람다식에서 어떤 부분을 변경할까?

  • 참조변수 f의 타입으로 유추하면 람다식이 두 개의 String 타입의 매개변수를 받는다. 따라서 매개변수 생략 가능.
  • 매개변수를 생략하면, equals만 남는데, 두 개의 String을 받아서 Boolean을 반환하는 equals라는 메소드이므로 String::equals로 변경한다.
BiFunction<String, String, Boolean> f = String::equals;

메소드 참조로 변경한 결과이다.

 

MyClass obj = new MyClass();
Function<String, Boolean> f = x -> obj.equals(x); //람다식
Function<String, Boolean> f2 = obj::equals; //메소드 참조

메소드 참조를 사용하는 경우가 한가지 더 있는데, 이미 생성된 객체의 메소드를 람다식으로 사용한 경우에는 클래스이름 대신 그 객체의 참조변수를 적어야 한다.

 

메소드 참조를 정리하면 다음과 같다.

종류 람다 메소드 참조
static 메소드 참조 x -> ClassName.method(x) ClassName::method
인스턴스 메소드 참조 (obj, x) -> obj.method(x) ClassName::method
특정 객체 인스턴스 메소드 참조 x -> obj.method(x) obj::method

 

생성자의 메소드 참조

생성자를 호출하는 람다식도 메소드 참조로 변환할 수 있다.

Supplier<MyClass> s = () -> new MyClass(); //람다식
Supplier<MyClass> s = MyClass::new; //메소드 참조

매개변수가 있는 생성자라면, 매개변수의 개수에 따라 알맞은 함수형 인터페이스를 사용하면 된다.

Function<Integer, MyClass> f = i -> new MyClass(i); //람다식
Function<Integer, MyClass> f = MyClass::new; //메소드 참조

BiFunction<Integer, String, MyClass> bf = (i, s) -> new MyClass(i, s); //람다식
BiFunction<Integer, String, MyClass> bf = MyClass::new; //메소드 참조

 

만약 배열을 생성할 때는 다음과 같이 사용한다.

Function<Integer, int[]> f = x -> new int[x]; //람다
Function<Integer, int[]> f2 = int[]::new; //메소드 참조

 

예제

public class MethodReferences {
    public static void main(String[] args) {
      //Function<String, Integer> f = s -> Integer.parseInt(s);
    	Function<String, Integer> f = Integer::parseInt;
        
        System.out.println(f.apply("100") + 200);
        
        // Supplier 입력 X, 출력 O
        // Supplier<MyClass> s = () -> new MyClass();
        Supplier<MyClass> s = MyClass:new;
        System.out.println(s.get());
        
        Function<Integer, MyClass> f2 = MyClass::new;
        MyClass m = f2.apply(100);
        System.out.println(m.iv);
        System.out.println(f2.apply(200).iv);
        
        Function<Integer, int[]> f3 = int[]::new;
        System.out.println(f3.apply(10).length);
        
	}
}

class MyClass{
	int iv;
    
    MyClass() {}
    
    MyClass(int iv){
    	this.iv = iv;
    }
}
// 결과
300
MyClass@5fd0d5ae
100
200
10

 

참조

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

www.notion.so/758e363f9fb04872a604999f8af6a1ae

 

댓글