Java/라이브스터디

람다식

목표

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

학습할 것 (필수)

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

 

 

람다식

 

람다 식은 자바 8 버전에서 추가된 기능입니다.

오라클 자바 튜토리얼에서는 람다식의 사용배경에 앞서 기존 익명클래스의 불편함에 대하여 얘기합니다.

 

하나의 메소드만 포함하는 인터페이스처럼 익명 클래스의 구현이 매우 단순할 경우 사용이 불편할 수 있다.

 

위와 같은 상황에서 불편함을 해결하기 위하여 등장한 기능이 람다식 이라고 합니다.

앞서 말한 하나의 메소드만 포함하는 인터페이스는 주로 함수형 인터페이스라고 부릅니다.

 

이런 함수형 인터페이스의 예시로 멀티쓰레드를 공부할 때 배웠던 Runnable 인터페이스가 떠올랐습니다.

친절하게도 어노테이션을 통하여 함수형 인터페이스임을 알려주고있네요.

 

 

람다식의 사용에 앞서서 기존 익명클래스를 이용한 표현법을 먼저 살펴보아야 한다고 생각했습니다. 

 

package me.ddings.lambda;

public class Foo {

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello");
            }
        });

        thread.start();
    }
}

 

package me.ddings.lambda;

public class Foo {

    public static void main(String[] args) {
        Thread thread = new Thread(() -> System.out.println("Hello"));

        thread.start();
    }
}

순서대로 익명클래스를 사용현 표현방법과 람다식을 사용한 표현방법 입니다.

당장에 눈으로 보기에도 5줄가량의 코드가 사라진 모습을 확인할 수 있었습니다. 이는 아마 함수형 인터페이스를 더 많이 사용해야한다면 더 체감될 것이라고 생각합니다.

 

 

 

 

 

 

람다식 사용법

람다식은 함수형 인터페이스를 간결하게 표현하기 위하여 사용되는 표현법입니다. 

 

 

인텔리제이에서도 하나의 추상메소드를 가지는 인터페이스는 람다식으로의 변환이 가능하다고 흑백처리 해주지만 2개 이상의 추상메소드를 가지는 인터페이스는 그런게 없는 것을 확인할 수 있습니다.

 

 

람다식은 위와같은 형식으로 표현됩니다. 

메소드의 파라미터 개수에 따라 괄호안에 사용될 이름을 표현하고 화살표 이후에는 구현부, 즉 메소드의 몸체 부분을 표현합니다.

 

 

package me.ddings.lambda;

public class Foo {

    interface Calculator{
        int add(int i, int j);
    }

    public static void main(String[] args) {
        Calculator addCalc = (i, j) -> {
            System.out.println(i + " + " + j + " = ");
            return i+j;
        };

    }
}

만일 특정한 작업을 취한다음 해당 값을 반환해야 한다면 중괄호를 이용하여 작업을 처리할 수도 있으며, 파라미터가 한개라면 괄호를 생략하여 표현할 수도 있습니다.

 

 

함수형 인터페이스

함수형 인터페이스란 하나의 추상메소드를 가지고있는 인터페이스를 의미합니다.

자바에서 제공하는 함수형 인터페이스는 java.util.function 패키지에서 확인할 수 있습니다.

 

 

Consumer<T>

public class Foo {
    public static void main(String[] args) {
        Consumer<String> consumer = (name)-> System.out.println(name);
        consumer.accept("ddings");
    }
}

 

데이터를 받아서 처리하는 기능을 합니다.

 

비슷하게 BiConsumer<T,U> 또는 IntConsumer 등등이 존재하는데, 

BiConsumer<T, U>의 경우 두 타입의 값을 받아서 처리하는 식으로 동작합니다.

 

 

Function<T,R>

public class Foo {
    public static void main(String[] args) {
        Function<String, Integer> function = (name)->25;
        Function<Integer, Integer> function1 = (number)->number + 10;
        System.out.println(function.apply("ddings"));
        System.out.println(function1.compose(function).apply("ddings"));
        System.out.println(function.andThen(function1).apply("ddings"));
    }
}

 

T의 값을 받아서 R의 타입으로 반환하는 기능을 합니다.

compose와 andThen을 이용하여 Function 변수끼리의 조합이 가능합니다.

 

compose의 경우 괄호안의 기능을 먼저 수행하고 바깥의 것을 수행하며

andThen의 경우 괄호밖의 기능을 먼저 수행하고 괄호 내부의 기능을 수행합니다.

 

즉 

compose 와 andThen 모두 function1( function( "ddings" ) 의 결과 )  의 형태가 됩니다.

 

 

BinaryOperator<T>

public class Foo {
    public static void main(String[] args) {
        BinaryOperator<Integer> binaryOperator = (numA, numB)-> numA + numB;
        System.out.println(binaryOperator.apply(10, 20));
    }
}

T 타입의 변수 두 개를 받아서 T타입의 값을 반환합니다.

 

 

Predicate<T>

public class Foo {
    public static void main(String[] args) {
        Predicate<String> predicate = (name)-> name.equals("ddings");
        System.out.println(predicate.test("Wurt"));
        System.out.println(predicate.test("ddings"));
    }
}

T 타입의 변수를 받아 사용자가 원하는 검증을 거친 뒤 boolean 값을 반환합니다.

 

 

Supplier<T>

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

T 타입의 값을 반환합니다.

 

 

 

 

 

Variable Capture

변수 캡처......는 말이 좀 이상하게 느껴지네요.

값 복사라고 생각하는게 더 옳다고 생각합니다.

 

Variable Capture란, 내부클래스에서 외부의 지역변수에 접근하는 것을 의미합니다.

 

package me.ddings.lambda;

import java.util.function.*;

public class Foo {

    int data = 20;

    void something(){
        int data = 10;
        class inner{
            inner(){
                System.out.println(data);
            }
        }
        inner inner = new inner();
    }

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

위 코드의 익명클래스는 일종의 내부클래스이고, 여기서 사용되는 data변수를 Captrue된 변수라고 부릅니다.

 

이해를 위해서 내부클래스의 scope개념에 대해 생각하게 되었는데,

먼저 객체가 생성되면 힙영역에 그 데이터가 저장됩니다.

반면에 메소드의 정보는 스택영역에 저장됩니다.

그리고 스태틱 값과 클래스관련 정보는 메소드영역에 저장되구요.

 

여기서 문제가 되는 부분은 메소드내에서 익명클래스 또는 람다식을 이용하여 선언된 객체를 외부로 반환하고, 그 객체가 메소드내의 지역변수의 값을 참조하는 경우를 생각해 볼 수 있습니다.

 

package lambda_demo;

public class VariableCaptureCase {

    interface Do{
        void method(int i);
    }

    public Do Dosomething(int data){
        return new Do() {
            @Override
            public void method(int i) {
                System.out.println(i + data);
            }
        };
    }

    public static void main(String[] args) {
        VariableCaptureCase test = new VariableCaptureCase();
        Do something = test.Dosomething(10);
        something.method(10);
    }
}

나름대로 적합하다고 판단되는 코드를 작성해보았습니다.

 

Dosomething( int data ) 메소드는 메소드이기 때문에 스택영역에 그 정보가 저장됩니다. 

main문에서 test.Dosomething( 10 ) 을 이용해 해당 메소드를 호출하면 스택영역에 해당 정보들이 올라가게 되고 값을 리턴하면 반환되겠죠.

 

이 반환값을 something 이라는 Do 인터페이스의 객체에 저장합니다. 

 

여기서 문제는 Dosomething 에서 반환되는 익명클래스가 Dosomething의 지역변수 data를 사용하고 있다는 사실입니다.

 

후에 something.method( 10 )을 호출하더라도 이미 Dosomething( int data ) 는 스택영역에서 사라진 후이기 때문에 에러가 발생할 것이라 추측할 수 있습니다.

 

하지만 놀랍게도 에러가 발생하지 않고 정상적으로 출력되는 모습을 확인할 수 있었습니다.

 

 

 

 

여기에서 사용되는 개념이 Variable Capture 입니다. 

지역변수의 값을 복사하여, 이후에 스택영역에서 해당 값이 사라진다고 하더라도 참조할 수 있도록 하는 것입니다. 

 

 

 

 

이렇게 캡처(복사)된 변수는 값의 수정이 불가능한 것을 컴파일 타임에 확인할 수 있는데, 그 이유에 대하여 생각해보았습니다.

 

먼저 멀티스레드 환경에서의 상황을 생각해보았습니다. 

 

VariableCaptureCase test = new VariableCaptureCase();
Do something = test.Dosomething(10);
something.method(10);

Thread thread01 = new Thread(()->{
    something.method(10);
});

Thread thread02 = new Thread(()->{
    something.method(10);
});

thread01.start();
thread02.start();

이렇게 main, thread01, thread02 세 개의 스레드에서 동일한 파마미터값을 가지고 해당 메소드를 호출하게되는 상황에서 만약 캡처된 지역변수값이 변화하게 되면 스레드마다 출력되는 값이 다를 것 입니다.

 

즉, 익명클래스의 결과값이 10을 캡처해서 10의 값을 사용한 연산이 이루어지길 기대하지만, 매 호출 시 마다 캡처된 값이 변화하면 기대한대로 값이 출력되지 않는다는 의미가 됩니다. 

 

10을 캡처했다는 의미는 이 익명클래스에서 10을 사용하고싶다는 의미가 되는데, 이 값이 변화하는건 마치

  1. 직업으로 전사를 골랐는데 스킬을 사용할 때마다 점점 마력이 상승하여 마법사가되는 상황.
  2. 고양이 사진을 찍었는데( capture ) 자꾸 강아지 사진으로 바뀌는 상황.

으로 생각하면 더 쉽게 와닿을 것 같습니다.

 

이런 이유때문에 컴파일러가 익명클래스, 또는 람다식에서 지역변수의 사용을 final 또는 사실상 final로 하라고 에러를 발생시킵니다.

 

 

반면에 멤버변수의 경우는 어떨까요?

 

멤버변수는 값을 조작하더라도 에러가 발생하지 않습니다.

그 이유는 데이터가 저장되는 영역이 클래스의 영역인 메소드영역이기 때문에 힙영역에서 언제든 해당 값을 참조할 수 있기 때문에 값을 조작할 수 있는 것일 껍니다.

 

 

앞서 익명클래스, 정확히는 함수형 인터페이스를 좀더 간결하고 보기쉽게 사용하기 위한 것이 람다식이었기 때문에 람다식에서도 해당 내용은 동일하게 적용됩니다. 

 

출처 : docs.oracle.com/javase/tutorial/java/javaOO/localclasses.html, jeong-pro.tistory.com/211

 

Local Classes (The Java™ Tutorials > Learning the Java Language > Classes and Objects)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

 

 

 

Shadowing

 

Variable Capture에 대한 내용을 오라클 공식문서를 통하여 확인해보던 중에 Shadwing이라는 단어가 눈에 들어왔습니다.

 

However, unlike local and anonymous classes, lambda expressions do not have any shadowing issues 

지역, 익명클래스와 다르게 람다식은 Shadowing 이슈가 없다고 하네요. Shadowing이 뭘까요?

 

 

package lambda_demo;

public class VariableCaptureCase {

    static int data = 20;

    interface Do{
        void method(int i);
    }

    public Do Dosomething(int data){
        System.out.println(data);
        return new Do() {
            @Override
            public void method(int data) {
                System.out.println(data);
            }
        };
    }

    public static void main(String[] args) {
        VariableCaptureCase test = new VariableCaptureCase();
        Do something = test.Dosomething(10);
        something.method(10);
        System.out.println(data);
    }
}

 

익명클래스인 Do()의 method() 에서의 dataDosomething()의 data를 가리고, Dosomething()의 data는 멤버변수 data를 가리는 것을 확인할 수 있으며, 이를 Shadowing이라고 부릅니다. 

 

즉, 동일한 이름의 변수선언으로 인하여 외부 scope의 변수를 가리는 현상인거죠

이 현상을 람다식에서는 확인할 수 없다고 하였으니 익명클래스 선언을 람다식으로 바꿔서 확인해보았습니다.

 

 

컴파일 타임에서 동일한 이름의 변수명을 사용하는걸 차단하는 모습을 확일할 수 있습니다.

이미 동일 범위내에서 같은 이름의 변수가 선언되어있다는 에러를 보여주네요.

 

그 이유에 대하여 오라클 자바 튜토리얼에서는 다음과 같이 설명합니다.

 

Lambda expressions are lexically scoped. This means that they do not inherit any names from a supertype or introduce a new level of scoping.

 

"람다식은 어휘적인 범위(scope)를 지닌다. 즉, 수퍼타입 또는 새로운 scope로부터 어떠한 이름도 상속받지 않는다."

 

즉, 익명 or 내부 클래스는 실제로 새로운 scope를 생성하는 것이지만 람다식은 보기에 그렇게 보일 뿐 실제로 외부 scope와 동일한 scope를 가진다. 라는 것을 의미한다고 생각합니다.

 

 

 

 

 

 

 

람다식으로 코드를 작성하다보면 이런 노란색 밑줄을 확인할 수 있었습니다.

 

 

 

해당 부분을 확인해보면 method reference, 메소드 참조에 관한 얘기를 확인할 수 있습니다.

메소드 참조는 또 뭘까요..?

 

 

 

메소드 참조

람다식은 익명클래스, 함수형 인터페이스를 표현하는 하나의 방법으로 볼 수 있지만, 어찌보면 하나의 익명메소드를 선언하는 것과 같습니다.

 

그리고 종종 해당 익명메소드에서 이미 존재하는 기존의 메소드를 호출하기만하는 경우도 존재합니다.

 

예를 들어 단순히 값의 출력을 한다거나, 덧셈 또는 뺄셈의 작업만 하는 경우가 있을 수 있습니다.

 

이러한 경우에 어차피 이미 존재하는 메소드를 굳이 작성하기 보다는 메소드 참조를 사용하여 간결하게 표현할 수 있습니다.

 

 

 

package me.ddings.lambda;

import java.util.function.*;

public class Foo {
    public static void main(String[] args) {
        Consumer<String> consumer = (name)-> System.out.println(name);
        consumer.accept("ddings");
    }
}
package me.ddings.lambda;

import java.util.function.*;

public class Foo {
    public static void main(String[] args) {
        Consumer<String> consumer = System.out::println;
        consumer.accept("ddings");
    }
}

메소드 참조를 사용하면 위 코드를 아래와 같이 간결하게 표현할 수 있습니다.

단순 덧셈의 경우에도 Integer::sum 과 같이 표현할 수 있습니다. 

 

이와 같은 메소드 참조에는 4개의 종류가 존재합니다.

 

  1. static 메소드의 참조
    1. 클래스명::메소드명
  2. 특정 객체의 인스턴스 메소드 참조
    1. 객체명::메소드명
  3. 특정 타입의 임의 객체의 인스턴스 메소드 참조
  4. 생성자의 참조
    1. 클래스명::new

 

 

public class Foo {
    public void HI(String s){
        System.out.println("Hi" + s);
    }
    public static void main(String[] args) {
        Foo foo = new Foo();
        Consumer<String> consumer = foo::HI;
        consumer.accept("ddings");
    }
}

특정 객체의 인스턴스 메소드 참조 예시

 

 

 

String[] stringArray = { "Barbara", "James", "Mary", "John",
    "Patricia", "Robert", "Michael", "Linda" };
Arrays.sort(stringArray, String::compareToIgnoreCase);

특정 타입의 임의 객체의 인스턴스 메소드 참조 예시.

출처 : docs.oracle.com/javase/tutorial/java/javaOO/methodreferences.html

 

Method References (The Java™ Tutorials > Learning the Java Language > Classes and Objects)

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com

 

 

public class Foo {
    private String name;
    public Foo(String name) {
        this.name = name;
    }

    public static void main(String[] args) {
        Function<String, Foo> function = Foo::new;
        Foo foo = function.apply("ddings");

        System.out.println(foo.name);
    }
}

생성자의 참조 예시

 

 

 

 

 

출처