11. 람다식

자바에 시달리면서 객체지향 프로그래밍에 대해 심도 있게 고찰하다가 자바스크립트의 function 향연이 어느 날 그리워졌다(...). 작년, 프론트엔드를 공부할 당시에는 클래스 문법(즉, 객체지향 프로그래밍)이 나한테 익숙하지 않은 상태로, 자바스크립트의 프로그래밍 페러다임이 ES6 기능을 받아들이면서 객체지향에 섞여서 함수형 프로그래밍 페러다임을 먼저 개념적으로 맞이하게 된 적이 있었다.

그럼, 당연히 자바도 그런 페러다임을 맞이하는 거 아냐..? 라는 궁금증이 생겼는데 역시는 역시나, 이미 자바는 함수형 프로그래밍을 위해서 세팅을 해뒀던 것이다. 람다식(Lambda Expression)이 바로 대표적인 함수형 프로그래밍을 위한 수단이었다.

1) 람다식의 정의

(1) 함수형 프로그래밍

객체지향 프로그래밍 패러다임은 객체를 중심으로 사고하고 프로그램을 작성하는 것이고, 반면 데이터를 함수로 연결하는 것을 중심으로 사고하고 프로그래밍을 하는 것을 함수형 프로그래밍 페러다임이라고 할 수 있다.

간단한 자바스크립트로 예시를 들어보자면...

// 함수형 프로그래밍 관점에서 분리해보자.
function App() {

  // 데이터
  const [count, setCount] = useState(0)

  // 계산
  const increase = (value) => value + 1

  // 액션
  const onClick = () => setCount(increase(count))
  
  // 선언적 패턴
  return <button onClick={onClick}>{count}</button>
}

어디서 많이 봤더만 싶더라니, 대표적인 자바스크립트 기반 라이브러리인 React의 모습과 똑같은 것이다. 아니 뭘 공부하는 지도 모르면서 코드를 짜고 있었다...

참조 링크
https://velog.io/@teo/functional-programming#%ED%95%A8%EC%88%98%ED%98%95-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9A%A9%EC%96%B4-%EB%8B%A4%EC%8B%9C%EC%93%B0%EA%B8%B0

(2) 람다식의 의의

함수형 프로그래밍이 자바에서 가지는 의미는 다형성의 구현이다. 상속과 인터페이스의 메소드 오버라이딩 외에 어떻게 가능한가 싶었는데, 간단하게 위의 그림과 연관지어서 생각해보면...

  1. 데이터데이터 처리 공간이 있다.
  2. 데이터 처리 수단을 마련해야 한다.
  3. 외부에서 함수를 제공한다.
  4. 데이터를 처리한다.
  5. 데이터를 다르게 처리하고 싶어졌다.
  6. 함수만 바꾸면 된다.

즉, 함수를 다르게 끼워넣으면서 다양한 결과를 기대, 다형성을 구현할 수 있는 것이다. 데이터 처리의 측면에 있어서 다형성 구현이라고 볼 수 있으며 이것이 함수형 프로그래밍의 특징이다. 표현 방식은 자바스크립트의 화살표 함수와 유사하다.

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

자바는 람다식을 익명 구현 객체로 변환한다. Calculable 인터페이스를 예시로 들어보자. 이것을 익명 구현 객체로 생성해보자.

// Calculable 인터페이스
public interface Calculable {
	// 추상 메소드
    void calculate(int x, int y)
}
// 익명 구현 객체
new Calculabe() {
	@Override
    public void calculate(int x, int y) {
    	// 처리 내용
        int result = x + y;
        System.out.println(result);
    }
}

해당 익명 구현 객체를 매개값으로 가지는 메소드 action(Calculable calculable)을 생각해본다. 이 메소드의 역할은 덩그러니 놓여진 데이터를 저 익명 구현 객체를 활용해서 처리하기 위한 데이터 처리 공간 제공의 역할을 맡는다.

// action 메소드 블록 공간이 데이터 처리부(공간)
public void action(Calculable calculable) {
    int x = 10; // 덩그러니 놓여진 데이터
    int y = 4; // 덩그러니 놓여진 데이터
    
    calculable.calculate(x, y); // 그리고 제공된 데이터 처리 목적 함수
} // Wow! 함수형 프로그래밍

action() 메소드를 호출할 때 매개값으로 람다식을 제공할 수 있다. action() 메소드 내부에서 calculable.calculate(x, y)를 실행하면 람다식의 중괄호 블록이 실행되면서 데이터가 처리된다.

// action 메소드 자체 : 데이터 처리부(공간)
// 람다식으로 데이터 처리
action((x, y) -> { // 이미 덩그러니 놓여진 데이터 x, y
	int result = x + y;
    System.out.println(result) 
})

여기서 다형성을 구현하는 방법은 새로운 람다식을 제공하면 된다. 즉, 데이터 처리를 달리 해주는(함수를 갈아끼우는) 것으로 다형성을 구현할 수 있다. 이렇게 하면 데이터 처리부에 있는 덩그러니 놓여진 데이터를 갖고 여러 결과를 내놓을 수 있는 것이다. 람다식을 갈아끼우고 처리부 역할을 맡는 메소드를 호출하면 끝.

(3) 함수형 인터페이스

인터페이스의 익명 구현 객체를 람다식으로 표현하려면 인터페이스가 단 하나의 추상 메소드만 가져야 한다. 이런 인터페이스를 함수형 인터페이스이라고 한다. 보통 함수형 인터페이스를 명시해주기 위해서 @FunctionalInterface를 붙이는 것이 권장된다.

아래는 함수형 인터페이스를 작성해서 람다식으로 다형성을 구현하는 예제다.

@FunctionalInterface
public interface Calculable {
    void calculate(int x, int y);
}
public class LambdaExample {
    public static void main(String[] args) {
        action((x, y) -> {
            int result = x + y;
            System.out.println("result : " + result);
        }); // action()은 데이터 처리부, 내부는 람다식

        action((x, y) -> {
            int result = x - y;
            System.out.println("result : " + result);
        }); // 얘도 마찬가지
    }

    // action 메소드 블록 공간이 데이터 처리부
    public static void action(Calculable calculable) {
        int x = 10; // 덩그러니 놓여진 데이터
        int y = 4; // 덩그러니 놓여진 데이터
        calculable.calculate(x, y); // 그리고 제공된 데이터 처리 목적 함수
    } // Wow! 함수형 프로그래밍
}

2) 람다식의 형태

(1) 매개변수가 없는 람다식

함수형 인터페이스에서 추상 메소드에 매개변수가 없을 경우를 상상하자.

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

그리고 해당 인터페이스를 매개값으로 가지는 action() 메소드를 상상하자.

public class Person {
	public void action(Workable workable) {
    	workable.work();
    }
}

이제 이것을 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 action() 메소드를 호출하는 메인 클래스를 작성해본다.

public class LambdaExample {
	public static void main(String[] args) {
    	Person person = new Person();
        
        person.action(() -> {
        	System.out.println("출근을 합니다.");
            System.out.println("프로그래밍을 합니다.");
        });
        
        person.action(() -> System.out.println("퇴근합니다.")); 
        // 실행문이 한 개면 중괄호 생략 가능(자바스크립트랑 비스무리)
    }
}

너무 복잡하게 생각하지 말기

  • 메소드가 데이터 처리부가 된다
  • 데이터에 대해서는 생각하지 않는다
  • 데이터를 어떻게 처리할 지가 가장 중요한 요소다
  • 함수형 인터페이스에는 추상 메소드가 하나 뿐이다
  • 람다식은 결국 인터페이스를 구현하는 것이므로 다형성의 예시가 된다

(2) 매개변수가 있는 람다식

매개변수가 있다는 것은 함수형 인터페이스의 추상 메소드의 매개변수 값이 존재한다는 것과 동일하다. 함수형 인터페이스에서 추상 메소드에서 매개변수가 있을 경우를 생각하자.

@FunctionalInterface
public interface Workable {
	void work(String name, String job);
}

@FunctionalInterface
public interface Speakable {
	void speak(String content);
}

그리고 해당 인터페이스를 매개값으로 가지는 action1() 메소드와 action2() 메소드를 상상하자.

public class person {
	public void action1(Workable workable) {
    	workable.work("홍길동", "프로게이머");
    }
    
    public void action2(Speakable speakable) {
    	speakable.speak("안녕하세요");
    }
}

이제 이것을 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 action1() 메소드와 action2() 메소드를 호출하는 메인 클래스를 작성해 본다.

public class LambdaExample {
	public static void main(String[] args) {
    	Person person = new Person();
        
        // action1() 호출하는 경우
        person.action1((name, job) -> {
        	System.out.println(name + "이 ");
            System.out.println(job + "을 합니다")'
        });
        
        // action2() 호출하는 경우
        person.action2((content) -> System.out.println(
        	"\"" + content + "\"라고 외칩니다.")); 
    }
}

매개변수가 있는 람다식은 매개변수를 람다식에 넣을 때 구체적인 타입 대신 var 키워드를 쓸 수도 있지만, 타입을 생략하고 작성하는 것이 일반적이다.

(3) 리턴값이 있는 람다식

함수형 인터페이스의 추상 메소드가 void로 선언된 것이 아닐 수도 있다. 즉, 리턴값이 존재하는 경우도 있을 수 있다. 함수형 인터페이스에서 void가 아닌 메소드를 상상하자.

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

그리고 해당 인터페이스를 매개값으로 가지는 action() 메소드를 상상하자.

public class person {
	public void action(Calculable calculable) {
    	double result = calculable.calculate(10, 4);
        System.out.println("결과 : " + result);
    }
}

이제 이것을 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 action() 메소드를 호출하는 메인 클래스를 작성해본다.

public class LambdaExample {
	public static void main(String[] args) {
    	Person person = new Person();
        
        person.action((x, y) -> {
        	double result = x + y;
            return result;
        });
        
        // 연산식처럼
        person.action((x, y) -> (x + y));
        
        // 정적 메소드 호출
        person.action((x, y) -> sum(x, y));
        // 이 부분은 자바스크립트랑 완존 동일하네
    }
    
    public static double sum(double x, double y) {
    	return (x + y);
    }
}

3) 참조 : 람다식 간략화

사실 킹왕짱인텔리제이의 기능으로 자연스럽게 구현할 수 있지만, 그래도 문법적인 지식은 익히는 것이 맞기 때문에 메소드 참조생성자 참조를 통한 람다식 문법의 간략화에 대해 숙지하고 넘어가봅시당.

(1) 메소드 참조를 통한 람다식 간략화

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

아까 람다식을 작성하는 과정과 유사하게 사고해보자. 함수형 인터페이스에서 void가 아닌 메소드를 상상하자.

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

그리고 해당 인터페이스를 매개값으로 가지는 action() 메소드를 상상하자.

public class person {
	public void action(Calculable calculable) {
    	double result = calculable.calculate(10, 4);
        System.out.println("결과 : " + result);
    }
}

람다식을 작성하는 메인 메소드를 작성하기 전, 람다식의 결과에서 활용하기 위한 인스턴스 메소드정적 메소드를 생성하자.

public class Computer {
	public double instanceMethod(double x, double y) {
    	return x + y;
    }
    
    public static double staticMethod(double x, double y) {
    	return x + y;
    }
}

이제 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 action() 메소드를 호출하는 메인 클래스를 작성해본다. 그럼 아마 이렇게 작성을 할 것이다.

public class MethodReferenceExample {
	public static void main(String[] args) {
    	Person person = new Person();
        
        // Computer 클래스의 정적 메소드를 람다식 결과에 활용
        person.action((x, y) -> Computer.staticMethod(x, y));
        
        Computer computer = new Computer();
        
        // Computer 클래스의 인스턴스 메소드를 람다식 결과에 활용
        person.action((x, y) -> computer.instanceMethod(x, y));     
    }
}

여기서 우린, 개발에 있어서 반복을 줄여야 한다는 생각을 해야 한다(?). 보면 (x, y)가 반복되는 것처럼 느껴진다(아님 말고). 자바는 심지어 저것조차 간략화시킬 수 있다.

public class MethodReferenceExample {
	public static void main(String[] args) {
    	Person person = new Person();
        
        // Computer 클래스의 정적 메소드를 람다식 결과에 활용
        // 메소드 참조
        person.action(Computer::staticMethod);
        
        Computer computer = new Computer();
        
        // Computer 클래스의 인스턴스 메소드를 람다식 결과에 활용
        // 메소드 참조
        person.action(computer::instanceMethod);     
    }
}

이것이 바로 메소드 참조다. 불필요한 매개변수를 삭제함으로써 매우 깔끔하게 처리할 수 있다. 아까 언급한 인텔리제이는 메소드 참조를 활용할 수 있는 람다식에 대해서는 권고를 해준다.

이런 식으로 '메소드 참조로 대체하세요~'라고 킹왕짱갓인텔리제이가 권고한다. 만만세. 보통 람다식이 단순하게 값을 람다식 결과에서 활용하는 메소드의 매개값으로 전달하는 역할만 할 때에 메소드 참조가 활용된다.

// 정적 메소드 

클래스(타입) :: 메소드
// 인스턴스 메소드

참조변수 :: 메소드

매개변수의 메소드 참조

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

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

이것 역시 메소드 참조로 표현이 가능한데, 우선은 람다식 작성 과정처럼 사고해보자. 함수형 인터페이스에서 void가 아닌 메소드를 상상하자.

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

그리고 해당 인터페이스를 매개값으로 가지는 ordering() 메소드를 상상하자.

public class Person {
	public void ordering(Comparable comparable) {
    	String a = "홍길동";
        String b = "김길동";
        
        int result = comparable.compare(a, b);
        
        if(result < 0) {
            System.out.println(a + "은 " + b + "보다 앞에 옵니다.");
        } else if (result == 0) {
            System.out.println(a + "은 " + b + "와 같습니다.");
        } else {
            System.out.println(a + "은 " + b + "보다 뒤에 옵니다.");
        }
    }
}

이제 이것을 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 ordering() 메소드를 호출하는 메인 클래스를 작성해본다.

public class MethodReferenceExample {
    public static void main(String[] args) {
        Person person = new Person();
        
        person.ordering((x, y) -> x.compareToIgnoreCase(y));
    }
}

이렇게 작성하면 인텔리제이가 역시 노란 줄을 그으면서 메소드 참조를 권고한다. 왜 이렇게 작성될까 매커니즘에 대해 고민해봤다.

  1. person.ordering((a, b) -> a.compareToIgnoreCase(b))를 작성했잖아.
  2. 근데 람다식을 안 썼더라면 person.ordering(Comparable 인터페이스를 implements해서 구현한 클래스의 compare 구현 메소드)를 사용했겠지? 그 구체적인 구현을 직접 클래스로 작성하는 것이 아닌 간략화한 것이 람다식의 존재 의의고
  3. Person 클래스에서 구현한 ordering() 메소드의 내부 내용을 보니까 result라는 int 타입을 이용해서 케이스에 따라 출력을 달리하는 기능이고, 그 result를 구하는 것은 Comparable 인터페이스의 compare() 메소드를 쓰고 있는데, 람다식은 그저 매개값을 전달하는 것에 불과하고 실질적인 로직은 내부에서 처리하는 별도의 메소드(compareToIgnoreCase())가 존재하기 때문에 메소드 참조로 더 간략화할 수 있다
 public int compare(String a, String b) {
      return a.compareToIgnoreCase(b);
 }
// 이렇게 구현이 된 셈
// 람다식으로 표현해도 람다식은 그저 매개값 a와 b를 전달해주는 것 뿐
// 실질적인 데이터 처리는 compareToIgnoreCase() 메소드가 처리한다.

ordering(String::compareToIgnoreCase)에서
Stringa의 클래스가 String이고, 참조하려는 메소드는 String 클래스에서 제공하는 compareToIgnoreCase()이기 때문에, 작성은 이렇게 이루어진다.

// 매개변수의 메소드 참조

a 매개값의 타입 클래스 :: a의 인스턴스 메소드

(2) 생성자 참조를 통한 람다식 간략화

아까 위에서 람다식이 단순히 매개값을 전달하는 것에 불과한 상황이 또 있다. 만약 람다식이 단순히 객체를 생성한다면, 실제로 람다식 결과에서 객체를 생성하는 역할은 생성자가 맡을 것이고 람다식은 단순 값 전달에 불과할 것이다.

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

이것을 생성자 참조라고 하며, 다음과 같이 표현한다.

생성자 클래스 :: new

만약 생성자가 오버로딩돼서 여러 개 있을 경우, 컴파일러는 함수형 인터페이스의 추상 메소드와 동일한 매개변수 타입과 개수를 가지고 있는 생성자를 찾아 실행한다. 만약 해당 생성자가 존재하지 않으면 컴파일 오류가 발생한다.

이것 역시 람다식 작성 과정처럼 사고해보자. 대신 여러 경우를 확인하기 위해서 이번에는 매개변수가 각각 1개, 2개인 추상 메소드를 보유한 함수형 인터페이스 2개를 작성한다.

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

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

또한, 역시 매개변수 타입이 함수형 인터페이스와 동일하고 개수가 맞는 생성자 2개(오버로딩)를 지닌 클래스를 하나 선언한다.

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

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

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

    @Override
    public String toString() {
        return "{ id : " + this.id + ", name : " + this.name + " }";
    }
}

그리고 해당 인터페이스들(Creatable1, Creatable2)을 매개값으로 가지는 getMember1(), getMember2() 메소드를 상상하자.

public class Person {
    public Member getMember1(Creatable1 creatable1) {
        String id = "winter";
        return creatable1.create(id); 
    }

    public Member getMember2(Creatable2 creatable2) {
        String id = "winter";
        String name = "한겨울";
        return creatable2.create(id, name);
    }
}

이제 이것을 인터페이스의 추상 메소드를 실질적으로 정의(다형성)하면서 구현한 람다식을 매개값으로 해서 getMember1() 메소드와 getMember2() 메소드를 호출하는 메인 클래스를 작성해본다.

public class ConstructorReferenceExample {
    public static void main(String[] args) {
        Person person = new Person();

        Member member1 = person.getMember1((x) -> new Member(x));
        System.out.println(member1); 

        System.out.println();

		Member member2 = person.getMember2((x, y) -> new Member(x, y));
        System.out.println(member2);
    }
}

역시나 이렇게 작성하니까 인텔리제이에서 생성자 참조를 권고한다. 람다식은 그저 매개값의 전달만 수행해주고 있고 실질적인 핵심 로직은 Member 클래스의 생성자가 수행하고 있기 때문이다.

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글