CS study/java

람다(Lambda)식의 정의와 함수형 인터페이스, 메서드 참조

블랑v 2024. 3. 23. 17:53

https://www.youtube.com/watch?v=4ZtKiSvZNu4

 

먼저 해당 포스팅은 다음 유튜브 설명을 참조했음을 미리 밝힌다.

이해에 큰 도움이 되니 먼저 확인해보길 권장한다.

 

이후 시간이 된다면 스트림 포스팅 역시 확인하면 도움이 될 것이다.

https://csg1353.tistory.com/214

기초 개념

자바에서 람다 표현식과 스트림 API는 코드를 더 간결하고 명료하게 만들어주는 강력한 기능이다.

람다 표현식은 익명 함수의 한 형태로, 간결한 방식으로 메소드를 전달할 수 있는 기능을 제공한다. 스트림 API는 데이터 컬렉션을 선언적으로 처리할 수 있도록 해주며, 람다 표현식과 함께 사용될 때 더욱 효과적이다.

 

 

람다(Lambda)

메서드를 하나의 식으로 표현한 것

 

람다 (lambda) 함수는 함수형 프로그래밍에서 중요한 개념 중 하나로, 익명 함수 (anonymous function)라고도 부른다.

람다 함수는 이름이 없는 함수로, 일반적으로 함수를 한 번만 사용하거나 함수를 인자로 전달해야 하는 경우에 매우 유용하게 사용된다.

 

즉, 간결한 방식으로 메소드를 생략한다는 것이 중점이다. 설명이 없어도 알아볼 수 있으니 말이다.

 

예를 들어 이런 식의 간단한 메서드가 있다고 가정해보자.

int testMethod(int a, int b) {
	return a + b;
}

 

이러한 메서드의 경우 입력 파라미터가 직관적으로 바로 return에 사용된다.

이를 줄여보고자 람다에서는 다음과 같은 화살표 함수를 사용하게 된다.

메서드를 선언할 필요 없이 생략하고, 필수적인 값만 넣은 것이다.

굳이 메서드를 선언할 필요 없이 이해할 수 있다면, 이처럼 줄일 수 있을 것이다.
(int a, int b) -> a + b

 

또한 컴파일러가 Parameter 타입을 추론하여 적용하기 때문에 다음과 같은 식으로도 줄일 수 있다.

//1. 파라미터 타입 추론
(int a, int b) -> a * b; //a와 b의 곱을 리턴
(a, b) -> a * b; //매개변수 타입 생략

//2. 괄호 생략
(a) -> a * a; //int a, return a * a;
a -> a * a; //괄호를 생략해도 동작한다.

//3. 메서드 바디 생략 
(String name, int i) -> { //입력 파라미터를 print로 출력하는 메서드
	System.out.println(name + "=" + i);
}

이를 더 줄여서 다음과 같이 사용 가능하다.
{ } 중괄호와 세미콜론까지 생략할 수 있다.
(String name, int i) ->
	System.out.println(name + "=" + i)

 

여기서 의문이 들 수도 있을 것이다.

그러면 이것은 메서드인가? 식인가? 객체인가?

 

람다식 자체는 메서드가 아니라 메서드를 간결하게 표현하기 위한 식이다.
그러나 람다식은 함수형 인터페이스의 구현체를 생성하는데 사용되므로, 결국 객체를 생성하는 수단으로 사용된다.

 

이를 조금 더 설명해 보겠다.

 

함수형 인터페이스? 

함수형 인터페이스(Functional Interface)는 오직 하나의 추상 메서드를 가지는 인터페이스를 말한다.

 

즉, 우리가 잘 아는 인터페이스 구조지만 '단 하나의 추상 메서드' 만을 가진다는 것이다.

@FunctionalInterface
public interface LikeHamsu {
    int OnlyOneMethod(int param1, int param2); //단 하나의 추상 메서드만을 가진다는 것이다.
}

 

여기서 든 의문은 다음과 같을 것이다.

Q. "굳이 왜 하나의 추상 메서드만 가져야 하지?"

해답은 다음과 같다.

 

함수형 인터페이스는 오직 하나의 추상 메서드만 가지기에, 람다식을 적용할 수 있다.

 

*(단, Defualt나 Static 등의 이를 구현하여 상속하지 않아도 되는 메서드는 존재할 수 있다.)

해당 클래스를 선언하고 직접 적용해보자.

@FunctionalInterface
public interface LikeHamsu {
    int operate(int param1, int param2); //operate 함수 선언
}
class Main {
    public static void main(String[] args) {
        //이런 식으로 추상 메서드의 파라미터와 내부 로직을 '람다식'으로 구현한 것이다.
        LikeHamsu likeHamsu = (x, y) -> x + y; //람다식의 형태 선언
        int a = 1;
        int b = 2;
        int result = likeHamsu.operate(a, b); // 람다식을 통해 구현한 메소드 호출
        System.out.println(result); //3의 결과 출력

    }
}

 

만일 추상 메서드가 하나가 아니라면?

 

 

함수형 인터페이스의 규칙은 명확하게 하나의 추상 메서드만을 요구하기 때문에, 이를 위반하는 어떠한 시도도 컴파일 에러로 이어진다.

 

만약 추상 메서드가 여러개라면, 람다식을 적용할 때 이 인터페이스의 어떤 함수를 사용할 지 알 수 없다.

즉, 'operate'를 사용할 지, 'operate2'를 사용할 지 자동적으로 적용하지 못한다는 것이다. 

파라미터 타입이 다르기에 구분할 수 있을 것 같지만, (int와 String의 입력 파라미터가 다르니까 구분할 수 있지 않나..?)
명확하게 하나의 추상 메서드만을 요구하는 것이 함수형 인터페이스의 특징이다.
다르게 이야기하면, 추상 메서드의 구현을 '람다식'으로 하기에, 단 하나의 메서드만 존재할 수 있는 것이다.

 

자바 8부터는 @FunctionalInterface 어노테이션을 사용해 이를 명시적으로 표시할 수 있다. 이러한 인터페이스는 람다 표현식 또는 메서드 참조를 통해 간단히 구현될 수 있으며, 함수형 프로그래밍을 가능하게 한다.

예를 들어, 자바의 java.util.function 패키지에는 다양한 함수형 인터페이스가 정의되어 있다.

다양한 함수형 인터페이스를 미리 적용해놓았다. 하나하나 람다식 구현해서 쓰라고..

 

 

대표적으로 사용하는 함수형 인터페이스들

직접 구현해도 상관없지만, Java에서는 대표적으로 바로 상속해서 만들 수 있는 템플릿을 제공한다.

 

Consumer<T>

void accept(T t)

T 타입의 객체를 인자로 받고, 반환 값이 없는 작업을 수행. 주로 객체를 소비하는 연산(예: 출력)에 사용

 

Supplier<T>

T get()

인자 없이 T 타입의 객체를 반환. 주로 객체의 공급자 역할을 할 때 사용

 

Function<T, R>

R apply(T t)

T 타입의 인자를 받아 R 타입의 결과를 반환. 입력을 출력으로 매핑하는 변환 작업에 사용

 

Predicate<T>

boolean test(T t)

T 타입의 객체를 인자로 받아 boolean 값을 반환. 객체가 특정 조건을 만족하는지 테스트하는 데 사용

 

UnaryOperator<T>

 T apply(T t)

Function<T, T>의 특수한 형태로, 입력 타입과 결과 타입이 같은 함수에 사용. 객체 변환 시 사용될 수 있다.

 

메서드 참조(Method Reference)

메서드 참조(Method References)는 람다 표현식이 단 하나의 메서드만을 호출하는 경우, 그 호출을 더 간결하게 표현하는 방법이다.

 

메서드 참조는 람다 표현식의 축약 형태로 볼 수 있으며,
클래스 이름::메서드 이름 또는 인스턴스 이름::메서드 이름의 형식을 사용한다.

 

메서드 참조는 크게 네 가지 유형으로 분류된다.

 

  1. 정적 메서드 참조 (Static Method References): 클래스의 정적 메서드를 참조한다.
  2. 인스턴스 메서드 참조 (Instance Method References): 특정 객체의 인스턴스 메서드를 참조한다.
  3. 특정 타입의 임의 객체에 대한 인스턴스 메서드 참조 (Arbitrary Object of a Particular Type): 특정 타입의 임의 객체의 인스턴스 메서드를 참조한다.
  4. 생성자 참조 (Constructor References): 클래스의 생성자를 참조한다.

예시를 들어 확인해보자.

 

1. 정적 메서드 참조

 

Function 객체의 추상 메서드의 apply의 경우 java.util.function에서 제공하는 함수형 인터페이스로, R, T중 T값을 입력받고, R를 반환한다.

        //R, T중 T값을 입력받고, R를 반환한다.
        //R apply(T t);
        Function<String, Integer> function = s -> Integer.parseInt(s);
        //여기서는 String을 입력받고, Integer를 반환하는 것이다.

 

만일 InteliJ를 사용한다면, 이처럼 메서드 참조가 가능한 람다식을 변환할 수 있다는 알림이 뜰 것이다.

// 람다 표현식
Function<String, Integer> lambda = s -> Integer.parseInt(s);

// 메서드 참조 예시
Function<String, Integer> methodReference = Integer::parseInt;

System.out.println(function.apply("123")); //양쪽 결과 모두 Integer.parseInt(s)가 사용된다.
#출력 결과 : 123

 

Integer.parseInt 역시 함수형 인터페이스처럼 입출력이 하나라서, Integer.parseInt(s)를 더 축약할 수 있다.

아래 사진을 보자. Integer 객체의 parseInt를 나타낸 부분이다.

 

함수형 인터페이스처럼, 어차피 입력이 하나이다..

 

1. Integer.parseInt(String) 메서드는 String 타입의 입력을 받아 int 타입의 결과를 반환한다.

2. 이는 Function<String, Integer> 인터페이스의 추상 메서드인 Integer apply(String t)동일한 시그니처를 가진다.

3.

람다 표현식 s -> Integer.parseInt(s)는 "String 타입의 입력 s를 받아 Integer.parseInt(s)를 호출하여 그 결과를 반환하라"는 의미를 가지고 있다.

여기서 s -> Integer.parseInt(s)가 하는 일은 Integer.parseInt 메서드를 직접 호출하는 것뿐이므로, 이를 메서드 참조 Integer::parseInt로 대체할 수 있다.

 

이것이 이해가 되었다면, 아래는 더욱 빠르게 이해할 수 있을 것이다.

2. 인스턴스 메서드 참조

1번과 유사하게, 만들어진 '인스턴스' 객체의 값을 참조한다.

String str = "hello";

// 람다 표현식
Supplier<Integer> lambda = () -> str.length();

// 메서드 참조
Supplier<Integer> methodReference = str::length;
여기서 str::length는 str 객체의 인스턴스 메서드 length()를 참조한다.

 

Supplier 함수의 경우는 다음과 같다.

단순히 아무런 입력 파라미터를 받지 않고 람다식에서 정의한 형태로 출력한다.

 

String 객체의 length() 역시도 입력 파라미터 없이 시그니쳐가 동일한 것을 알 수 있다.

단, 여기서는 str::lengthstr이라는 구체적인 String 객체의 인스턴스 메서드 length()를 참조한 것이다.

 

그래서 이런 식으로 인스턴스가 없다면 바로 에러가 발생한다.

 

3. 특정 타입의 임의 객체에 대한 인스턴스 메서드 참조

// 람다 표현식
Function<String, Integer> lambda = s -> s.length();

// 메서드 참조
Function<String, Integer> methodReference = String::length;

 

String::length라는 메서드 참조 표현은 "모든 String 객체에 대해, 해당 객체의 length() 메서드를 호출하라"는 의미이다.

 

람다 표현식의 경우: 
s -> s.length()는 명시적으로 s라는 파라미터를 받고, s.length()를 호출한다.

메서드 참조: 
String::length는 좀 더 암시적이다. 
여기서는 String 타입의 어떤 객체가 주어지면, 그 객체의 length() 메서드를 호출하라고 지시한다. 
이 형태의 메서드 참조는 구체적인 객체를 명시하지 않고, String 타입의 객체가 사용될 것임을 알린다.

 

그러니까 쉽게 말해서 'String의 입력이 주어지니, 이 입력에 있어 String class가 기본적으로 가지고 있는 length 메서드를 호출'하라는 뜻인 거다. 람다 표현식의 의미와 동일하다.

 

만약 length() 메서드가 파라미터를 필요로 했다면, 그에 맞는 함수형 인터페이스를 사용해야 하며, Function<String, Integer>와는 매치되지 않을 것이다. (아마 BiFunction<String, Integer, ReturnType>을 써야 한다고 생각한다.)

 

4. 생성자 참조

// 람다 표현식
Supplier<List<String>> lambda = () -> new ArrayList<>();

// 메서드 참조
Supplier<List<String>> methodReference = ArrayList::new;

 

여기서 ArrayList::newArrayList의 생성자를 참조하여, List<String> 타입의 객체를 생성한다.

3번과 마찬가지로, ArrayList 객체의 타입을 참조하여 기본적으로 ArrayList가 가지고 있는 '생성자' 를 불러내어 return하는 것이다.

 

정리

요약하면, 람다식은 다음과 같은 특징을 가진다.

  • 식: 람다식은 메서드를 한 줄의 코드로 간단히 표현한 식이다.
  • 메서드를 대체: 람다식은 익명 메서드(익명 함수)로 사용되며, 인터페이스의 추상 메서드를 구현할 때 사용된다.
  • 객체 생성: 람다식은 함수형 인터페이스의 구현체를 생성하는데 사용되며, 이는 결국 자바에서 객체를 의미한다.