오늘은 몰랐으면 내일은 알면 된다

2022-11-08 (4) 람다식(Lambda Expression) 본문

Java/JAVA 개발자 양성과정

2022-11-08 (4) 람다식(Lambda Expression)

마스터피쓰 2022. 11. 8. 12:42

[함수형 프로그래밍 Functional Programming]

: 함수를 정의하고 이 함수를 데이터 처리부로 보내 데이터를 처리하는 기법을 의미한다. 데이터는 데이터를 가지고만 있을 뿐, 처리 방법이 정해져 있지 않아 외부에서 제공된 함수에 의존한다.

데이터 처리부에서는 제공된 함수의 입력값으로 데이터를 넣고, 함수에 정의된 처리 내용을 실행한다. 동일한 데이터라도 함수A를 제공해서 처리하는 결과와 함수B를 제공해서 처리하는 결과는 다를 수 있다. 일종의 데이터 처리 다형성인 것이다.

 

[람다식 Lambda Expression]

: 람다식은 위의 그림과 같이, 데이터 처리부에 제공되는 함수 역할을 하는 매개변수를 가진 중괄호 블록이다.

데이터 처리부는 람다식을 받아 매개변수에 데이터를 대입하고 중괄호를 실행시켜 처리한다.

(매개변수, ...) -> { 처리 내용 }

자바는 람다식을 익명 구현 객체로 변환한다.

스레드 포스팅에서 사용했던 예시를 다시 살펴보자.

//익명 객체
Thread t = new Thread(new Runnable() {

    private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Override
    public void run() {
        String threadName = Thread.currentThread().getName();
        for(int i=0; i<10; i++) {
            System.out.println("현재 사용중인 스레드 이름(익명):" + threadName + "," + new Date());
        }
    }
});
t.start();

//람다식
new Thread(() -> {
    String threadName = Thread.currentThread().getName();
    for(int i=0; i<10; i++) {
        System.out.println("현재 사용중인 스레드 이름(람다):" + threadName + "," + new Date());
    }
}).start();

Runnable 인터페이스는 run() 이라는 추상 메소드를 하나만 가지는 함수형 인터페이스이다.

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

예시 코드의 윗부분은 익명 구현 객체를 Thread 생성자의 매개변수로 직접 넣는 코드고, 아래의 코드는 해당 내용을 람다식으로 변환한 것이다. 결과는 동일하다.

 

또 다른 예시를 보자. 여기에 Calculable 이라는 함수형 인터페이스가 있다.

public interface Calculable {
	void calculate(int x, int y);
}

이 인터페이스를 가지고 익명 구현 객체를 만든다고 하면, 다음과 같이 만들 수 있다.

new Calculable() {
    @Override
    public void calculate(int x, int y) {
        System.out.println(x + y); //처리 내용이 들어가는 곳
    }
};

이것을 람다식으로 표현하면 다음과 같다. 

(x,y) -> { 처리 내용 };

간단하게, 람다식은 익명 구현 객체를 표현한 것이라고 생각하자. 그렇다면 람다식은 인터페이스 타입의 매개변수에 대입될 수 있을 것이다.

예를들어 아래의 action() 메소드는 Calculable 인터페이스를 매개변수로 가지는데, 여기를 람다식으로 대체할 수 있다는 이야기다.

public static void action(Calculable c) {
    int x = 5;
    int y = 10;
    c.calculate(x, y); //데이터를 제공하고 추상 메소드를 호출
}
action((x,y) -> {
	int result = x + y;
    System.out.println(result);
});

action()을 호출할 때 매개값으로 람다식을 제공하면 어떤과정을 거쳐 실행되는지 살펴보자.

 

1. action() 메소드에서 c.calculate(x,y)를 실행하면,

2. 람다식의 중괄호 블록이 실행되면서 데이터가 처리된다.

 

즉 action() 메소드는 제공된 함수(람다식)를 이용해서 내부 데이터(x, y)를 처리하는 데이터 처리부 역할을 하는 것이다.

처음 그림을 재사용해서 설명하면 다음과 같다. 어떤 람다식을 매개로 제공하느냐에 따라서 데이터 처리 결과는 달라질 것이다.

추상 메소드의 내부 로직을 람다식에서 정의한다고 생각하면 되겠다.

 

이처럼 인터페이스의 익명 구현 객체를 람다식으로 표현하려면, 인터페이스가 단 하나의 추상 메소드만 가지는 함수형 인터페이스(Funtional Interface)여야 한다.

인터페이스 람다식
public interface Runnable {
    void run();
}
( ) -> { ... }
@FunctionalInterface
public interface Calculable {
    void calculate(int x, int y);
}
(x, y) -> { ... }

 

인터페이스가 함수형 인터페이스임을 보장하기 위해서는 @FuntionalInterface 어노테이션을 붙여준다. 해당 어노테이션을 붙이면 컴파일 과정에서 추상 메소드가 하나인지 검사하기 때문에 정확한 함수형 인터페이스를 작성할 수 있게 도와준다.

위의 Calculable 인터페이스도 다음과 같이 어노테이션을 붙여줄 수 있다.

만약 어노테이션을 붙인 상태에서 추상 메소드를 둘 이상 작성하려고 하면 다음과 같이 컴파일 에러가 난다.

 

[매개변수가 없는 람다식]

: 함수형 인터페이스의 추상 메소드에 매개변수가 없는 경우, 람다식은 다음과 같이 작성할 수 있다.

실행문이 두개 이상인 경우에는 중괄호를 생략할 수 없고, 하나일 경우에만 생략할 수 있다.

( ) -> {
    실행문;
    실행문;
}
( ) -> 실행문;

함수형 인터페이스 Workable을 정의하자.

@FunctionalInterface
public interface Workable {
	void work();
}

그리고 이 Workable을 매개변수로 받는 action을 정의한다.

public static void action(Workable w) {
    w.work();
}

이런경우, 람다식은 아래와 같이 작성될 수 있다.

action(() -> System.out.println("살려줘"));

 

[매개변수가 있는 람다식]

: 함수형 인터페이스의 추상 메소드에 매개변수가 있는 경우, 람다식은 다음과 같이 작성할 수 있다.

타입을 쓰거나 var를 써줄수도 있다지만, 매개변수만 쓰는 경우가 일반적이라고 하므로 해당되는 것만 기록해 놓는다.

(매개변수, ...) -> {
    실행문;
    실행문;
}
(매개변수, ... ) -> 실행문

위에서 정의해놓은 Workable에 매개변수를 추가해보자.

@FunctionalInterface
public interface Workable {
	void work(String worker1, String worker2);
}

그리고 String 데이터를 가지는 데이터 처리부 action()을 정의하자

public static void actionParam(Workable w) {
    String worker1 = "개미1";
    String worker2 = "개미2";
    w.work(worker1, worker2);
    //또는 w.work("개미1","개미2");
}

이런 경우, 람다식은 아래와 같이 작성될 수 있다.

//실행부가 여럿일때
actionParam((worker1,worker2) -> {
    System.out.println(worker1 + "는 뚠뚠");
    System.out.println(worker2 + "는 뚠뚠");
});
//실행부가 하나일때
actionParam((worker1, worker2) -> System.out.println(worker1 + worker2 + " 둘 다 뚠뚠"));

 

[리턴값이 있는 람다식]

: 함수형 인터페이스의 추상 메소드에 매개변수가 있는 경우, 람다식은 다음과 같이 작성할 수 있다. return 문 하나만 있는 경우에는 중괄호와 return 모두 생략할 수 있다.

(매개변수, ... ) -> {
    실행문;
    return 값;
}
(매개변수, ... ) -> 값

여기에 Calculable이라는 함수형 인터페이스가 있다.

@FunctionalInterface
public interface Calculable {
	int calculate(int x, int y);
}

그리고 Person이라는 클래스에는 이 인터페이스를 매개변수로 하는 action() 이 있다.

public class Person {
	public void action(Calculable c) {
		int result = c.calculate(10, 5);
		System.out.println(result);
	}
}

여기서 calculate() 가 호출되었을 때의 처리와 결과 반환을 해줄 람다식을 작성하면 아래와 같다.

Person p = new Person();
//실행문이 여러개인 경우
p.action((x, y) -> {
    return x + y;
});
//리턴문이 하나인 경우
p.action((x, y) -> (x + y));
//리턴문이 하나인데 메소드를 호출하는 경우
p.action((x, y) -> sum(x, y));

//호출되는 메소드
public static int sum(int x, int y) {
	return x + y;
}

 

[메소드 참조]

: 메소드를 참조해서 매개변수의 정보 및 리턴 타입을 알아내 람다식에서 불필요한 매개변수를 제거하는 것을 목적으로 한다.

무슨 소리일까? 예를들면 Math.max()를 호출하는 람다식은 다음과 같이 작성할 수 있다.

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

그런데 이렇게 작성하는 경우 람다식은 단순히 left 와 right를 max의 매개값으로 전달하는 역할만 한다.

이럴 때 메소드 참조를 이용하면 코드를 더 확 줄일 수 있다.

Math :: max

 

[정적 메소드와 인스턴스 메소드 참조]

//정적 메소드 참조
클래스 :: 메소드

//인스턴스 메소드 참조
참조변수 :: 메소드

위에서 사용했던 Person에 인스턴스 메소드를 추가해보자.

public class Person {
	public void action(Calculable c) {
		int result = c.calculate(10, 5);
		System.out.println(result);
	}
	public int calc(int x, int y) {
		return x + y;
	}
}

Math 클래스의 static 메소드인 max와, Person 클래스의 인스턴스 메소드인 calc를 참조하는 방법은 아래와 같다.

Person p = new Person();
p.action(Math :: max); //결과 10
p.action(p :: calc); //결과 15

 

[매개변수의 메소드 참조]

: 람다식에서 제공되는 a 매개변수의 메소드를 호출해서 b 매개변수를 매개값으로 사용하는 경우도 있다.

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

 

이를 메소드 참조로 바꾸려면 다음과 같이 작성할 수 있다.

클래스 :: instanceMethod

작성 방법은 정적 메소드와 동일해 보이지만, 뒤에 오는 메소드가 인스턴스 메소드라는 점에서 차이가 있다.

//정적 메소드 참조
클래스 :: 메소드

//인스턴스 메소드 참조
참조변수 :: 메소드

//매개변수 메소드 참조
클래스 :: 인스턴스메소드

다음의 예제는 String 클래스의 인스턴스 메소드인 compareToIgnoreCase를 참조하여 a.compareToIgnoreCase(b)를 호출했을 때 a가 b보다 먼저 오면 음수, 동일하면 0, 나중에 오면 양수를 리턴한다.

 

먼저 함수형 인터페이스 Comparable을 만든다.

@FunctionalInterface
public interface Comparable {
	int compare(String a, String b);
}

Person 클래스에 Comparable을 매개로 갖는 ordering() 메소드를 만든다.

public class Person {
	public void ordering(Comparable c) {
		String s1 = "가";
		String s2 = "나";
		
		int result = c.compare(s1, s2);
		
		if(result < 0) System.out.println(s1 + "는 " + s2 + "보다 앞에 온다");
		else if(result == 0) System.out.println(s1 + "은 " + s2 + "와 같다");
		else System.out.println(s1 + "는 " + s2 + "보다 뒤에 온다");
	}
}

사용 방법은 아래를 참고하자.

Person p = new Person();
p.ordering(String :: compareToIgnoreCase);

 

[생성자 참조]

: 람다식이 단순히 객체를 생성하고 리턴하도록 구성된다면, 람다식을 생성자 참조로 대체할 수 있다. 예를들어 아래의 코드에서 람다식은 단순히 객체를 생성하고 리턴만 한다.

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

위의 코드는 아래와 같이 변경할 수 있다.

클래스 :: new

생성자가 오버로딩 되어있는 경우, 컴파일러는 함수형 인터페이스의 추상 메소드와 동일한 매개변수 타입과 개수를 가지고 있는 생성자를 찾아 실행한다.

해당 생성자가 없는 경우에는 컴파일 오류가 발생한다.

예시를 보자.

다음의 두 함수형 인터페이스를 정의한다.

@FunctionalInterface
public interface Creatable {
	public Member create(String id);
}


@FunctionalInterface
public interface Creatable2 {
	public Member create(String id, String name);
}

Member에 생성자를 오버로딩한다.

public class Member {
	private String id;
	private String name;
	
	public Member(String id) {
		super();
		this.id = id;
	}

	public Member(String id, String name) {
		super();
		this.id = id;
		this.name = name;
	}
}

Person 클래스는 아래와 같이 작성한다.

public class Person {
	public Member getMember1(Creatable1 c1) {
		String id = "s1";
		Member m = c1.create(id);
		return m;
	}
	public Member getMember2(Creatable2 c2) {
		String id = "s2";
		String name = "s2Name";
		Member m = c2.create(id, name);
		return m;
	}
}

생성자 참조를 위해서는 아래와 같이 작성한다.

Person p = new Person();
p.getMember1(Member :: new);
p.getMember2(Member :: new);

생성자를 참조하는 방법은 두가지 모두 동일하지만, 함수형 인터페이스의 매개변수의 개수에 따라 실행되는 Member 생성자가 다르다는 것을 알 수 있다.