순간을 성실히, 화려함보단 꾸준함을

15주차 과제: 람다식 본문

백기선님과 함께 하는 자바 스터디

15주차 과제: 람다식

폭발토끼 2021. 8. 7. 11:45

목표

자바의 람다식에 대하여 학습하시오

학습할 것(필수)

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

람다식

람다식이란?

- 람다식이란 간단히 말하면 '식(expression)'으로 표현한 것. 함수를 간략하면서도 명확한 식으로 표현할 수 있게 해준다. 람다식은 메소드 이름과 반환값이 없으므로 '익명함수(anonymous function)'이라고도 한다.

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

람다식 작성법

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

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

를 람다식으로 표현하면

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

반환값이 있는 메소드인 경우, return문 대신 '식(expression)'으로 대신 할 수 있다. 식의 연산결과가 자동적으로 반환값이 된다.
이때 '세미콜론(;)'을 붙지지 않는 것에 유의하자

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

람다식에 선언된 매개변수의 타입은 추론이 가능한 경우 생략이 가능하다.
대부분인 경우 생략이 가능하므로 보통 적지 않는다.

(int a,int b) -> a>b ? a : b ===========> (a,b) -> a>b ? a : b

또한, 매개변수가 만약 1개인 경우에는 '괄호()' 도 생략할 수 있다.

(a) -> System.out.println("hello") ================> a -> System.out.println("hello")

다만 타입을 선언한 상태라면 괄호를 생략하면 컴파일 에러가 발생한다.

(int a) -> System.out.println("hello") =================> int a -> System.out.println("hello") //Compile Error

위와 마찬가지로 '괄호{}' 안에 문장이 단 한문장이라면 '괄호{}'를 생략할 수 있다.
그러나 괄호 안에 return 문구를 사용한 return 문이라면 생략을 불가하다

(int a,int b) -> { return a>b ? a : b;}        //ok
(int a,int b) -> return a>b ? a : b            //Compile Error

함수형 인터페이스

위에서 람다식을 사용할 때 메소드와 동등한 것 처럼 보였지만 사실 람다식은 '익명 클래스'의 객체와 동등하다

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

위의 소스는

new Object{
    int max(int a,int b){            //max 는 임의의 이름을 갖다 붙인거다
        return a>b ? a : b;
    }
}

위의 람다식으로 정의된 익명 객체의 메소드를 우린 어떻게 호출할 수 있을까? 참조변수가 있어야 객체의 메소드를 호출 할 수 있으니깐 어떤 참조변수를 선언해보자

타입 f = (int a,int b) -> a>b ? a : b;

이때, 참조변수 f는 참조형이니 클래스와 인터페이스만이 가능하다.
그래야지만 우린 람다식의 메소드를 호출할 수 있기 때문이다.

예를 들어보자

interface MyFunction{
    public abstract int max(int a,int b);
}

MyFunction 이라는 인터페이스가 존재하고 이 인터페이스는 max 라는 추상메소드를 단 한개 가지고 있다고 가정해 보자.
그러면 이 인터페이스를 구현한 익명 클래스의 객체는 다음과 같이 생성할 수 있다

MyFunction f = new Myfunction{
                public int max(int a,int b){
                    return a>b ? a : b;
                }
            };
int ret = f.max(2,9);

위의 max 메소드를 람다식으로 표현한다면

MyFunction f = (a,b) -> a>b? a : b;
int ret = f.max(2,9);

이렇게 바꾸어 줄 수 있다.
이처럼 MyFunction 인터페이스를 구현한 익명 클래스를 람다식으로 대체가 가능한 이유는, 람다식도 익명 객체이고 MyFunction 인터페이스를 구현한 익명 객체의 메소드 max()와 람다식의 매개변수의 타입과 개수 그리고 반환값이 모두 일치하기 때문이다.

이러한 이유 때문에 인터페이스를 통해 람다식을 다루기로 결정되었으며, 람다식을 다루기 위한 인터페이스를 함수형 인터페이스(functional interface) 라고 부르기로 하였다.

@FunctionalInterface
interface MyFunction{
    public abstract int max(int a,int b);
}

단, 함수형 인터페이스는 반드시 하나의 추상 메소드만 정의되어 있어야 한다 라는 제약이 있다.
그래야 람다식과 인터페이스의 메소드가 1:1로 매칭이 되기 때문이다.
반면에 default 와 static 메소드는 개수의 제약이 존재하지 않는다

함수형 인터페이스 타입의 매개변수와 반환타입

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

Myfunction 이라는 인터페이스가 존재한다고 가정해보자. 이때 만약 메소드의 매개변수가 Myfunction 타입이라면, 이 메소드를 호출할 때 람다식을 참조하는 참조변수를 매개변수로 지정해야한다는 뜻이다.

void aMethod(Myfunction f){        //매개변수의 타입이 함수형 인터페이스
    f.myMethod();                //MyFunction에 정의된 메소드 호출
}

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

또한 참조변수를 사용하지 않고 직접 람다식을 매개변수로 지정하는 것 또한 가능하다

aMethod(()->System.out.println("myMehtod()"));        //람다식을 매개변수로 지정

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

MyFunction myMethod(){
    MyFunction f = ()->{};
    return f;                        //동등한 람다식 : return ()->{};
}

람다식을 참조변수로 다룰 수 있다는 것은 메소드를 통해 람다식을 주고받을 수 있다는 것을 의미한다.
즉, 변수처럼 메소드를 주고받는 것이 가능해진 것이다

@FunctionalInterface
interface Myfunction{
    void run();
}
class Lambda{
    static void execute(Myfunction f){
        f.run();
    }
    static Myfunction getMyFunction(){
        Myfunction f = () -> System.out.println("f3.run()");
        return f;
    }

    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.println("run()"));
    }
}
출력
f1.run()
f2.run()
f3.run()
f1.run()
run()

람다식의 타입과 형변환

람다식은 함수형 인터페이스를 참조할 수 있는 것일뿐, 람다식의 타입이 함수형 인터페이스의 타입과 일치하는 것은 아니다. 람다식은 익명 객체이고 익명 객체는 타입이 없다.
정확히는 타입은 있지만 컴파일러가 임의로 이름을 정하기 때문에 알 수 없는 것이다. 그래서 대입 연산자의 양변의 타입을 일치시키기 위해 형변환이 필요하다.

MyFunction f = (MyFunction) (()->{});

람다식은 Myfunction 인터페이스를 직접 구현하지 않았다. 그러나 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 생략이 가능하다.
주의할 점은 Object 타입으로 형변환 할 수 없다는 것이다. 람다식은 오직 함수형 인터페이스 로만 형변환이 가능하다.
만약 Object 타입으로 변환시키고 싶으면

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

이렇게 함수형 인터페이스로 변환을 먼저 한 후 Object로 변환하면 된다.

람다의 scope

로컬 클래스와 익명 클래스는 자신들의 로컬 변수들이 쉐도윙이 된다. 그러나 람다는 람다를 감싸고 있는 메소드와 스코프가 같다.

class Lambda{
    private void run(){
        int baseNumber = 10;
        class LocalClass{
            void printBaseNumber(){
                int baseNumber=11;
                System.out.println(baseNumber);
            }
        }

        Consumer<Integer> integerConsumer = new Consumer<Integer>() {
            @Override
            public void accept(Integer baseNumber) {
                System.out.println(baseNumber);
            }
        };

        IntConsumer printInt = baseNumber -> System.out.println(baseNumber);    //Compile Error
    }
    public static void main(String[] args) {
        Lambda lambda = new Lambda();
        lambda.run();
    }
}

위의 예제를 보자.
baseNumber라는 변수를 클래스 run 에 선언을 하였다. 그리고 로컬 클래스, 익명 클래스, 람다를 정의해 주고 baseNumber를 재정의 해주었다. 로컬 클래스와 익명 클래스에서는 baseNumber를 재정의 해준 값으로 쉐도윙이 되는걸 확인할 수 있다.
그러나 람다에서 baseNumber에서 컴파일 에러가 발생한다.

바로 컴파일 에러가 발생하는 이유는 람다와 람다를 감싸고 있는 클래스(Lambda)가 같은 scope를 가지고 있기 때문에 발생하는 오류이다.

Variable Capture

먼저 Lambda Capturing(람다 캡쳐링) 이 무엇인지 부터 알아보자
람다에 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수를 참조하는 행위를 뜻한다.

람다에서 접근이 가능한 변수는 총 3가지가 존재한다.

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

먼저 지역변수만 변경이 불가하다.
람다는 지역 변수가 존재하는 스택 영역에 직접적으로 접근을 하지 않는다. 지역 변수를 람다가 동작하는 쓰레드의 스택영역에 복사를 하는 것 이다.

왜 그러는 것일까??
람다는 별도의 쓰레드에서 실행이 가능하다. 따라서 원래 존재하는 지역변수가 위치해 있는 쓰레드가 사라져서 해당 지역변수 또한 사라졌는데도 불구하고, 람다가 실행 중인 쓰레드는 계속 동작 중 일수도 있는 것이다.
그러나 람다에서 이미 사라진 지역변수를 계속적으로 참조를 하고 있다면???당연히 에러가 발생할 것이다.
이를 방지하고자 자신의 스택영역에 지역 변수들을 복사하는 것이다.

그러나 만약 멀티 쓰레드 환경에서는 다수의 쓰레드가 람다식을 사용하게 되면서 람다 캡처링이 많이 발생하게 될텐데 외부 변수 값의 불변성을 만족하지 못하면서 동시에 동기화(sync) 문제가 발생할 수 있다.
이런 이유 때문에 지역변수는 final, Effectively Final 의 제약조건을 가지게 된 것이다.

Effectively Final이란?

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

static 변수와 인스턴스 변수는 힙 영역에 저장되어 있고, 힙 영역은 모든 쓰레드가 공유하고 있다. 때문에 값의 쓰기가 발생한다고 해서 문제가 발생하지는 않는다.

@FunctionalInterface
interface MyFunction{
    void myMethod();
}
class Outer{
    int val = 10;   //Outer.this.val

    class Inner{
        int val = 20;   //this.val

        void method(int i){        //void method(final int i)
            int val=30;        //final int val = 30;
            //i=14;            //Compile Error

            MyFunction 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 Inner
}//End Outer

class Lambda{

    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

위의 내용에서 람다에서 참조되는 지역 변수는 변경이 불가하다고 하였다.
즉, 위의 소스에서 i 가 해당된다. 따라서 i에 값에 다른 값을 할당하려고 한다면 컴파일 에러가 발생한다.

람다식 내에서 참조하는 지역변수는 자동으로 상수화가 되기 때문이다.
final 키워드를 붙여주지 않아도 상수취급한다.

람다의 스코프는 람다를 감싸고 있는 클래스와 같다고 하였는데 this.val을 출력하면 클래스 Inner에 선언된 int val = 20에 접근하는 것을 확인할 수 있다.

Inner 클래스와 Outer클래스의 변수들은 변경이 가능한 것 또한 확인할 수 있다.

만약 람다식을 사용하지 않고 익명 클래스로 정의를 한번 해보자

@FunctionalInterface
interface MyFunction{
    void myMethod();
}
class Outer{
    int val = 10;   //Outer.this.val

    class Inner{
        int val = 20;   //this.val

        void method(int i){
            int val=30;
            //i=14;

            MyFunction f = new MyFunction() {
                int val=40;                //익명 클래스 안에 val을 또 선언했다. 선언하지 않으면 컴파일 에러가 발생한다.
                @Override
                public void myMethod() {
                    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();
        }
    }
}
class Lambda{

    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.method(100);
    }
}
//출력
i : 100
val : 40
this.val : 41
Outer.this.val : 11

차이점이 존재하는 것을 볼 수 있다. this.val은 더이상 Inner클래스를 참조하는 것이 아닌 자신의 익명클래스 내의 범위를 참조하는 것을 알 수 있다. 만약 int val=40을 빼버리게 된다면 System.out.println("this.val : " + (++this.val));에서 컴파일 에러가 발생한다.

java.util.function 패키지(https://docs.oracle.com/javase/8/docs/api/java/util/function/package-summary.html)

매번 새로운 함수형 인터페이스를 선언해서 정의하지 말고, 가능하면 이미 정의되어 있는 패키지의 인터페이스를 활용하는 것이 좋다.
그래야 함수형 인터페이스에 정의된 메소드 이름도 통일되고, 재사용성이나 유지보수 측면에서도 좋다.

함수형 인터페이스메소드설명
java.lang.Runnablevoid 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

function<T,R>

//main
class Lambda{
    public static void main(String[] args) {
        Plus10 plus10 = new Plus10();
        System.out.println(plus10.apply(10));        //20 이 출력된다.
    }
}
//class Plus10
public class Plus10 implements Function<Integer,Integer> {
    @Override
    public Integer apply(Integer integer) {
        return integer+10;
    }
}

위의 Plus10의 클래스를 선언해도 좋지만 그냥 람다식으로 바로 표현해보자

class Lambda{
    public static void main(String[] args) {
        Function<Integer,Integer> plus10 = integer -> integer+10;
        System.out.println(plus10.apply(10));
    }
}

Supplier

class Lambda{
    public static void main(String[] args) {
        Supplier<Integer> supplier = () -> 10;
        System.out.println(supplier.get());
    }
}

Consumer

class Lambda{
    public static void main(String[] args) {
        Consumer<Integer> consumer = integer -> System.out.println(10);
    }
}

Predicate

class Lambda{
    public static void main(String[] args) {
        Predicate<Integer> predicate = integer -> {
            if(integer==10)return true;
            else return false;
        };
        System.out.println(predicate.test(10));
    }
}

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

함수형 인터페이스메소드설명
BiConsumer<T,U>void accept(T t,U u)두개의 매개변수만 있고, 반환값이 없다
BiPredicate<T,U>boolean test(T t,U u)조건식을 표현하는데 사용됨. 매개변수는 둘, 반환값은 boolean
BiFunction<T,U,R>R apply(T t,U u)두 개의 매개변수를 받아서 하나의 결과로 반환

UnaryOperator 와 BinaryOperator

Function과 거의 동일하지만 차이점은 매개변수 타입과 반환타입의 타입이 모두 일치한다는 점이 있다.

함수형 인터페이스메소드설명
UnaryOperatorT apply(T t)Function의 자손, Function과 달리 매개변수와 결과의 타입이 같다
BinaryOperatorT apply(T t, U u)BiFunction의 자손, BiFunction과 달리 매개변수와 결과의 타입이 같다

Function의 합성

-Function-
default <V> Function<T,V> andThen(Function<? super R, ? extends V> after)
default <V> Function<V,R> compose(Function<? super V, ? extends T> before)
static <V> Function<T,T> identity()

수학에서 두 함수를 합성해서 하나의 새로운 함수를 만들어 낼 수 있는 것처럼 , 두 람다식을 결합하여 하나의 새로운 람다식을 만들어낼 수 있다. 이때, 순서에 따라 결과값이 달라지니 이를 유의하자

함수 f와 g가 있다고 가정해보자

- andThen : f.andThen(g) 는 함수 f를 먼저 적용하고, 그 다음에 함수 g를 적용한다.
- compose : f.compose(g) 는 함수 g를 먼저 적용하고, 그 다음에 함수 f를 적용한다

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

        System.out.println(plus10.andThen(multifly).apply(10));    //40출력
    }
}

10을 먼저 더한 후 2를 곱해 40이 결과값으로 나온다

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

        System.out.println(plus10.compose(multifly).apply(10));    //30출력
    }
}

2를 먼저 곱한 후 10을 더해서 30이 결과값으로 나온다

메소드,생성자 레퍼런스

람다식이 하나의 메소드만 호출하는 경우에는 '메소드 레퍼런스(method reference)'라는 방법으로 람다식을 더 간단히 줄일 수 있다.

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

이런 람다식이 존재하고 있을때

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

이렇게 표현할 수 있다.

메소드 참조 방법

1) 스태틱 메소드 참조 => 타입::스태틱 메소드
2) 특정 객체의 인스턴스 메소드 참조 => 객체레퍼런스::인스턴스 메소드
3) 생성자 참조 => 타입::new
4) 임의 객체의 인스턴스 메소드 참조 => 타입::인스턴스 메소드

public class Greeting {
    private String name;

    public Greeting(){}

    public  Greeting(String name){
        this.name=name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public String hello(String name){
        return "hello"+name;
    }

    public static String hi(String name){
        return "hi"+name;
    }
}
class Lambda{
    public static void main(String[] args) {
        //1.스태틱 메소드 참조
        UnaryOperator<String> hi = Greeting::hi;
        System.out.println(hi.apply("Boomrabbit : red"));

        //2.특정 객체의 인스턴스 메소드 참조
        Greeting greeting = new Greeting();
        UnaryOperator<String> hello = greeting::hello;
        System.out.println(hello.apply("BoomRabbit : blue"));

        //3.1 생성자 참조(Supplier:파라미터는 없지만 결과값은 있음)
        //디폴트 생성자는 결과값이 없는 것이 아니다. 클래스의 타입이 결과값이다.
        Supplier<Greeting> supplier = Greeting::new;
        Greeting greeting1 = supplier.get();
        greeting1.setName("BoomRabbit : pink");
        System.out.println(greeting1.getName());

        //3.2 생성자 참조(Function : 파라미터도 존재하고 결과값도 존재함)
        Function<String,Greeting> function = Greeting::new;
        Greeting greeting2 = function.apply("BoomRabbit : green");
        System.out.println(greeting2.getName());

        //4. 임의 객체의 인스턴스 메소드 참조
        String[] names = {"Umiy","Yangsu","Garang"};
        Arrays.sort(names, String::compareToIgnoreCase);
        System.out.println(Arrays.toString(names));
    }
}
hiBoomrabbit : red
helloBoomRabbit : blue
BoomRabbit : pink
BoomRabbit : green
[Garang, Umiy, Yangsu]

예전 자바를 사용하던 사람들은 보통 정렬을 할때 Comparator 인터페이스를 사용하여 compare 메소드를 구현했을 것이다. 이걸 좀 더 간편하게 람다로 표현할 수도 있다.

Arrays.sort(names, new Comparator<String>() {                // 람다표현 : Arrays.sort(names, (o1, o2) -> 0)
        @Override
        public int compare(String o1, String o2) {
        return 0;
    }
})

그러나 Comparator 인터페이스도 함수형 인터페이스 이고 compare 메소드는 비교후 리턴시켜주는 역할 밖에 하지 않으니 메소드 레퍼런스를 사용하여 더 간단하게 표현이 가능하다.

String 객체의 인스턴스 메소드를 참조하여 사용하면 된다.

public int compareToIgnoreCase(String str) {
    return CASE_INSENSITIVE_ORDER.compare(this, str);
}

REFERECE

- https://perfectacle.github.io/2019/06/30/java-8-lambda-capturing/

- 자바의정석(남궁민)

- 더 자바, Java 8 (백기선)

'백기선님과 함께 하는 자바 스터디' 카테고리의 다른 글

스터디 후기  (0) 2021.08.08
[번외] final 과 static 키워드  (0) 2021.08.01
[리뷰] 14주차 : 제네릭  (0) 2021.07.31
14주차 과제: 제네릭  (0) 2021.07.31
[리뷰] 13주차 : I/O  (0) 2021.07.25