🔎 람다가 필요한 이유

상황에 따라 변하는 문자열 데이터가 있을 때, 구체적인 값을 메서드 안에 두는 것이 아니라 매개 변수를 통해 외부에서 전달 받아 메서드의 동작을 달리 해서 재사용성을 높였다. 그리고 단순한 값이 아닌, 코드 조각 자체를 메서드에 전달할 때는 인스턴스를 전달하고 외부에서 전달되는 인스턴스에 따라 각각 다른 코드 조각을 실행하도록 했다. 익명 클래스를 사용해도 익명 클래스의 참조값을 매개 변수에 전달해야 한다.

 

<데이터 값을 메서드에 전달 (Value Parameterization)>

package lambda.start;

public class Ex0RefMain {
    public static void main(String[] args) {
        printText("Java");
        printText("Spring");
    }

    static void printText(String text) {
        System.out.println("프로그램 시작");
        System.out.println("Hello " + text);
        System.out.println("프로그램 종료");
    }
}

 

<코드 조각을 메서드에 전달 (Behavior Parameterization)>

package lambda;

public interface Procedure {
    void run();
}
package lambda.start;

import lambda.Procedure;

import java.util.Random;

// 정적 중첩 클래스 사용
public class Ex1RefMainV1 {

	/* 매개 변수를 통해 인스턴스 전달 가능
	전달 받은 인스턴스의 메서드를 통해 필요한 코드 조각 실행 */
    public static void hello(Procedure procedure) {

        long startNs = System.nanoTime();
        procedure.run();
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

	// Procedure 인터페이스 구현체
    static class Dice implements Procedure {

        @Override
        public void run() {  // 필요한 코드 조각 구현
            int randomValue = new Random().nextInt(6) + 1;
            System.out.println("주사위의 눈: " + randomValue);
        }
    }

	// Procedure 인터페이스 구현체
    static class Sum implements Procedure {

        @Override
        public void run() {  // 필요한 코드 조각 구현
            for (int i = 1; i <= 3; i++) {
                System.out.println("i = " + i);
            }
        }
    }

    public static void main(String[] args) {

        Procedure dice = new Dice();
        Procedure sum = new Sum();
        hello(dice);
        hello(sum);
    }
}

 

<익명 클래스 사용>

package lambda.start;

import lambda.Procedure;

import java.util.Random;

public class Ex1RefMainV3 {

    public static void hello(Procedure procedure) {

        long startNs = System.nanoTime();
        procedure.run();
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    public static void main(String[] args) {

        hello(new Procedure() {
            @Override
            public void run() {
                int randomValue = new Random().nextInt(6) + 1;
                System.out.println("주사위의 눈: " + randomValue);
            }
        });

        hello(new Procedure() {
            @Override
            public void run() {
                for (int i = 1; i <= 3; i++) {
                    System.out.println("i = " + i);
                }
            }
        });
    }
}

/*
주사위의 눈: 1
실행 시간: 2652417ns
i = 1
i = 2
i = 3
실행 시간: 135709ns
*/

결국 메서드에 인수로 전달할 수 있는 것은 어떤 값이나, 인스턴스의 참조다. 코드 조각을 전달하기 위해서는 위와 같이 클래스를 정의하고 메서드를 만들고, 인스턴스까지 생성해서 참조값을 전달하는 험난한 과정을 매번 거쳐야 하는 걸까? 코드 조각을 직접 때려 박을 수만 있다면 얼마나 좋을까…

 

🤔 함수 vs 메서드

함수(Function)메서드(Method)는 모두 어떤 작업을 수행하는 코드의 묶음이다. 하지만 객체 지향 프로그래밍 관점에서는 약간의 차이가 있다.

객체와의 관계를 비교해보면, 함수는 객체와 상관없이 독립적으로 존재해야 한다. 그래서 호출 시에 객체 인스턴스를 생성할 필요가 없다. 객체 지향 언어 뿐만 아니라 C언어 등의 절차적 언어에서는 모든 로직이 함수 단위로 구성된다. 파이썬이나 자바스크립트처럼 클래스 밖에서도 정의할 수 있는 함수 개념을 지원하면, 이걸 그냥 함수라고 부른다. 반면, 자바는 모든 함수가 특정 클래스 안에 소속되어야 하기 때문에 함수가 독립적으로 존재할 수 없다. 메서드는 객체(클래스)에 속해 있는 함수를 말한다. 따라서 객체의 상태에 직접 접근하거나, 객체가 제공해야 할 기능을 구현할 수 있는 것이다.

이처럼 함수와 메서드는 수행하는 역할 자체는 같지만, 어딘가에 소속되어 있는지 여부와 호출 방식에서 차이가 난다고 이해하면 된다.


⚔️ 람다

Lambda는 자바 8부터 도입된 함수형 프로그래밍을 지원하기 위한 핵심 기능이다. 람다는 익명 함수로, 이름 없이 함수를 표현할 수 있다.

익명 클래스를 사용했을 때는 new 키워드, 생성할 클래스명, 메서드명, 반환 타입 등 여러 가지를 모두 나열해야 해서 복잡하다. 눈살이 찌푸려진다…

Procedure procedure = new Procedure() {
	@Override
	public void run() {
		System.out.println("hello! lambda...");
	}
};

하지만, 람다를 사용한다면 그냥 매개 변수와 본문만 적으면 끝이다. 심지어 변수를 통해 람다를 실행할 수도 있다.

Procedure procedure = () -> {
	System.out.println("hello! lambda...");
};

procedure.run();  // 변수를 통해 람다 실행

 

💥 주의 사항

이렇게 보면 람다가 그냥 귀찮아서 임시방편으로 코드 조각을 던지고 마는 느낌이 있다. 하지만 람다도 익명 클래스처럼 클래스가 만들어지고, 인스턴스가 생성된다.

package lambda.lambda1;

import lambda.Procedure;

public class InstanceMain {
    public static void main(String[] args) {

		// 익명 클래스
        Procedure procedure1 = new Procedure() {
            @Override
            public void run() {
                System.out.println("hello! lambda...");
            }
        };

        System.out.println("class.class: " + procedure1.getClass());
        System.out.println("class.instance: " + procedure1);

		// 람다 표현식
        Procedure procedure2 = () -> {
            System.out.println("hello! lambda...");
        };

        System.out.println("lambda.class: " + procedure2.getClass());
        System.out.println("lambda.instance: " + procedure2);
    }
}

/*
class.class: class lambda.lambda1.InstanceMain$1
class.instance: lambda.lambda1.InstanceMain$1@30f39991

lambda.class: class lambda.lambda1.InstanceMain$$Lambda/0x0000040001003618
lambda.instance: lambda.lambda1.InstanceMain$$Lambda/0x0000040001003618@4a574795
*/

출력 결과를 보면 알 수 있듯이, 익명 클래스의 경우 $로 구분하고 뒤에 숫자가 붙고, 람다는 $$로 구분하는 것을 볼 수 있다. 그리고 람다로 생성한 인스턴스의 참조값도 존재하는 것을 확인할 수 있다.


⛳️ 함수형 인터페이스

함수형 인터페이스는 단 하나의 추상 메서드가 선언된 인터페이스를 말한다. 람다는 이렇게 단일 추상 메서드(Single Abstract Method)를 가지는 인터페이스에만 할당 가능하다.

왜 그럴까? 추상 메서드가 1개인 경우와 여러 개인 경우를 비교해보자.

package lambda.lambda1;

@FunctionalInterface
public interface SamInterface {
    // 추상 메서드가 하나인 경우
    void run();
}
package lambda.lambda1;

public interface NotSamInterface {
	// 추상 메서드가 하나가 아닌 경우
    void run();
    void go();
}
package lambda.lambda1;

public class SamMain {
    public static void main(String[] args) {

        SamInterface sam = () -> {
            System.out.println("SAM");
        };

        sam.run();
    }

//    NotSamInterface notSam = () -> {
//        System.out.println("not sam");
//    };

    /*
    java: incompatible types: lambda.lambda1.NotSamInterface is not a functional interface
    multiple non-overriding abstract methods found in interface lambda.lambda1.NotSamInterface
    */
}

// SAM

함수형 인터페이스가 아닌 인터페이스에 할당하게 되면 보다시피 컴파일 오류가 발생한다. 람다를 인터페이스에 담기 위해서는 하나의 메서드(함수) 선언만 존재해야 하는데, 인터페이스 안에 여러 메서드를 선언한다면 도대체 어디에 할당해야 하는지 알 수가 없다. 이런 문제를 해결하기 위해 단 하나의 추상 메서드만을 포함하는 함수형 인터페이스에만 람다를 할당할 수 있도록 제한한 것이다. 이로써 람다식과 인터페이스의 메서드가 일대일로 연결될 수 있게 된 것이다.

 

🤔 @FunctionalInterface가 뭐지?

public class Car {
	public void move() {
		System.out.println("차가 이동합니다...");
	}
}
public class ElectricCar extends Car {
	@Override
	public void movee() {
		System.out.println("전기차가 이동합니다...");
	}
}

아까 살펴봤듯이 함수형 인터페이스는 단 하나의 추상 메서드만을 포함하는 인터페이스이고, 람다는 이 함수형 인터페이스에만 할당 가능하다고 했다. 이때 해당 인터페이스에 @FunctionalInterface를 붙여주면, 혹여나 여러 개의 추상 메서드가 작성됐을 때 컴파일 오류를 터뜨려서 함수형 인터페이스임을 보장할 수 있게 해준다.

따라서 함수형 인터페이스라면 @FunctionalInterface 애노테이션을 필수로 추가하는 것이 권장된다.


🖼️ 람다와 시그니처

람다를 함수형 인터페이스에 할당할 때는 메서드의 형태를 정의하는 요소인 메서드 시그니처(메서드 이름, 매개 변수의 수와 타입, 반환 타입)가 일치해야 한다.

 

예를 들어보자.

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

위의 함수형 인터페이스 안에 있는 메서드의 시그니처는 다음과 같다.

  • 이름: apply
  • 매개 변수: int, int
  • 반환 타입: int

 

익명 함수인 람다는 이름을 제외한 나머지 시그니처들이 apply() 메서드와 일치해야 한다. 따라서 아래와 같이 람다를 작성하면 된다.

MyFunction myFunction = (int a, int b) -> {
	return a + b;
}

매개 변수나 반환 타입이 없으면 아래와 같이 하면 된다.

@FunctionalInterface
public interface Procedure {
	void run();
}
Procedure procedure = () -> {
	System.out.println("hello, lambda...");
}

🫥 람다의 생략

람다의 철학은 최대한 줄일 수 있는 만큼 줄이자는 것이다. 바로 아래 코드를 보자.

package lambda.lambda1;

import lambda.MyFunction;

public class LambdaSimple1 {
    public static void main(String[] args) {

		// 기본 표현
        MyFunction function1 = (int a, int b) -> {
            return a + b;
        };

        System.out.println("function1: " + function1.apply(1, 2));

        // 단일 표현식인 경우, 중괄호와 리턴 생략 가능
        MyFunction function2 = (int a, int b) -> a + b;
        System.out.println("function2: " + function2.apply(1, 2));

        // 단일 표현식이 아닐 경우, 중괄호와 리턴 모두 필수
        MyFunction function3 = (int a, int b) -> {
            System.out.println("람다 실행...");
            return a + b;
        };

        System.out.println("function3: " + function3.apply(1, 2));
    }
}

/*
function1: 3
function2: 3
람다 실행...
function3: 3
*/

위와 같이 a + b처럼 단일 표현식인 경우, 중괄호와 return문을 생략할 수 있다. 표현식의 결과가 자동으로 반환값이 되는 것이다. 하지만 단일 표현식이 아닐 경우에는, 중괄호와 return문 모두 필수다.

아래는 매개 변수와 반환값이 둘 다 없는 코드다.

package lambda.lambda1;

import lambda.Procedure;

public class LambdaSimple2 {
    public static void main(String[] args) {

        Procedure procedure1 = () -> {
            System.out.println("hello! lambda...");
        };

        procedure1.run();

        // 중괄호 생략 가능
        Procedure procedure2 = () -> System.out.println("hello! lambda...");
        procedure2.run();
    }
}

/*
hello! lambda...
hello! lambda...
*/

 

📡 타입 추론

다음 람다 코드를 보자.

MyFunction function1 = (int a, int b) -> a + b;

 

MyFunction 인터페이스를 찾아가서 apply() 메서드를 보면, 이미 매개 변수에 (int a, int b)가 정의되어 있다.

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

 

따라서 람다에서 매개 변수의 타입 정보를 생략할 수 있다. 아래 코드를 확인해보자.

package lambda.lambda1;

import lambda.MyFunction;

public class LambdaSimple3 {
    public static void main(String[] args) {

		// 타입 생략을 하지 않은 상태
        MyFunction myFunction = (int a, int b) -> a + b;

        // MyFunction 타입을 통해 타입 추론 가능, 람다는 타입 생략 가능
        MyFunction function2 = (a, b) -> a + b;
        int result  = function2.apply(1, 2);
        System.out.println("result = " + result);
    }
}

// result = 3

이처럼 자바 컴파일러는 람다가 사용하는 함수형 인터페이스의 메서드 타입을 기반으로 람다의 매개 변수와 반환값의 타입을 추론하기 때문에 람다 코드에서 타입을 생략하는 것이 가능하다. 그리고 반환 타입은 원래 명시적으로 입력할 수 없다. 이 또한 컴파일러가 자동으로 추론해준다.

 

추가로 매개 변수가 1개면, 매개 변수 타입은 물론이고 소괄호도 생략해도 무방하다.

package lambda.lambda1;

public class LambdaSimple4 {
    public static void main(String[] args) {
    
        MyCall call1 = (int value) -> value * 2;  // 기본
        MyCall call2 = (value) -> value * 2;  // 타입 추론
        MyCall call3 = value -> value * 2;  // 매개 변수 1개, () 생략 가능

        System.out.println("call1.call(5); = " + call1.call(5));
        System.out.println("call2.call(5); = " + call2.call(5));
        System.out.println("call3.call(5); = " + call3.call(5));
    }

    interface MyCall {
        int call(int value);
    }
}

/*
call1.call(5); = 10
call2.call(5); = 10
call3.call(5); = 10
*/

람다를 사용할 때는 생략할 수 있는 부분들이 있다면 적극적으로 생략하도록 하자.


📩 람다의 전달

람다는 함수형 인터페이스를 통해 변수에 대입하고, 메서드에 전달할 수 있고, 람다를 반환할 수도 있다.

package lambda.lambda2;

import lambda.MyFunction;

// 람다를 변수에 대입
public class LambdaPassMain1 {
    public static void main(String[] args) {

        MyFunction add = (int a, int b) -> a + b;
        MyFunction sub = (int a, int b) -> a - b;

        System.out.println("add.apply(1, 2): " + add.apply(1, 2));
        System.out.println("sub.apply(1, 2): " + sub.apply(1, 2));

        MyFunction cal = add;  // 람다를 변수에 대입
        System.out.println("cal(add).apply(1, 2): " + cal.apply(1, 2));

        cal = sub;  // 람다를 변수에 대입
        System.out.println("cal(sub).apply(1, 2): " + cal.apply(1, 2));
    }
}

/*
add.apply(1, 2): 3
sub.apply(1, 2): -1
cal(add).apply(1, 2): 3
cal(sub).apply(1, 2): -1
*/

 

이게 어떻게 가능하지? 아까 언급했듯이 람다도 익명 클래스와 마찬가지로 클래스가 만들어지고 인스턴스를 생성할 수 있다고 했다. 그럼 그 생성된 인스턴스를 바탕으로 변수에 값을 저장한다는 것은, 그냥 변수에 람다로 생성된 인스턴스의 참조값이 저장되는 것이다.

MyFunction add = (a, b) -> a + b;  // 1. 람다로 인스턴스가 생성되면
Myfunction add = x001;  // 2. 람다 인스턴스의 참조값이 반환되어, add 변수에 대입된다.

MyFunction cal = add;
MyFunction cal = x001;  // 3. cal 변수에 1에서 생성되었던 인스턴스의 참조값이 대입된다.

 

이와 같은 원리로, 람다를 매개 변수를 통해 메서드에 전달할 수도 있다.

package lambda.lambda2;

import lambda.MyFunction;

// 람다를 메서드(함수)에 전달
public class LambdaPassMain2 {
    public static void main(String[] args) {

		// 람다로 생성된 인스턴스 참조값이 add와 sub에 각각 대입된다.
        MyFunction add = (int a, int b) -> a + b; 
        MyFunction sub = (int a, int b) -> a - b;

        System.out.println("변수를 통해 전달");
        calculate(add);  // 참조값을 전달
        calculate(sub);  // 참조값을 전달

        System.out.println("람다를 직접 전달");
        calculate((a, b) -> a + b);
        calculate((a, b) -> a - b);
    }

	// 매개 변수가 MyFunction 함수형 인터페이스 (람다 전달 가능)
    static void calculate(MyFunction function) {
        int a = 1;
        int b = 2;

        System.out.println("계산 시작");
        int result = function.apply(a, b);
        System.out.println("계산 결과: " + result);
    }
}

/*
변수를 통해 전달
계산 시작
계산 결과: 3
계산 시작
계산 결과: -1

람다를 직접 전달
계산 시작
계산 결과: 3
계산 시작
계산 결과: -1
*/

과정을 자세히 뜯어보자면…

calculate((a, b) -> a + b);  // 1. 람다 인스턴스가 생성
calculate(x001);  // 2. 참조값 반환 및 매개 변수에 전달

// 3. 메서드 호출, 매개 변수에 참조값이 대입된다.
void calculate(MyFunction function = x001)

 

마지막으로 람다 자체를 반환할 수도 있다. 아래 코드를 보도록 하자.

package lambda.lambda2;

import lambda.MyFunction;

// 람다를 반환
public class LambdaPassMain3 {
    public static void main(String[] args) {
        
        MyFunction add = getOperation("add");
        System.out.println("add.apply(1, 2): " + add.apply(1, 2));

        MyFunction sub = getOperation("sub");
        System.out.println("sub.apply(1, 2): " + sub.apply(1, 2));

        MyFunction somethingElse = getOperation("somethingElse");
        System.out.println("somethingElse.apply(1, 2): " + somethingElse.apply(1, 2));
    }

    // 람다를 반환하는 메서드
    static MyFunction getOperation(String operator) {
        switch (operator) {
            case "add":
                return (int a, int b) -> a + b;
            case "sub":
                return (int a, int b) -> a - b;
            default:
                return (int a, int b) -> 0;
        }
    }
}

/*
add.apply(1, 2): 3
sub.apply(1, 2): -1
somethingElse.apply(1, 2): 0
*/

보다시피 getOperation 메서드는 반환 타입이 MyFunction 함수형 인터페이스인 것을 볼 수 있다. 따라서 람다를 반환할 수 있고, 반환된 람다로 생성된 인스턴스 참조값을 변수에 대입해서 로직을 수행할 수 있다.

여기까지 람다를 그냥 정리하자면, 그냥 함수형 인터페이스를 구현한 익명 클래스 인스턴스와 같은 개념으로 이해하면 된다. 즉, 람다를 변수에 대입한다는 것은 람다로 생성한 인스턴스 참조값을 대입한다는 말이고, 람다를 메서드의 매개 변수나 반환값으로 넘긴다는 말도 람다로 생성한 인스턴스 참조값을 넘기는 것이다.

 

🤔 고차 함수?

함수를 값처럼 다룰 수 있는 함수를 고차 함수라고 한다. 고차 함수에는 함수를 인자로 받는 함수와 함수를 반환하는 함수가 있다.

<함수를 인자로 받는 함수>

static void calculate(MyFunction function) {
	// ...
	// 람다 함수를 전달 받고 있다.
}

 

<함수를 반환하는 함수>

static MyFunction getOperation(String operator) {
	// ...
	// 람다 함수를 반환하고 있다.
	return (a, b) -> a + b;
}

자바에서 람다는 함수형 인터페이스를 통해서만 전달할 수 있다고 했는데, 자바에서 “함수를 주고 받는다” 라는 의미는, “함수형 인터페이스를 구현한 어떤 객체(람다, 익명 클래스 등)를 주고 받는다.” 의 의미와 완전히 같다.

profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글