[Java] Lambda, 람다 규칙, 자바 제공 함수형 인터페이스, 메서드 참조

벼랑 끝 코딩·2025년 4월 23일
0

Java

목록 보기
34/40
post-thumbnail

람다를 사용해야 하는 이유

코드에서 중요한 것은 변하는 것과 변하지 않는 부분을 분리하여
중복되는 코드를 제거하는 것이다.

값 매개변수화

만약 특정 값만 변화한다면 메서드를 생성하여 값을 매개변수로 전달해 중복을 제거할 수 있다.

동작 매개변수화

특정 값만 변화하는 것이 아닌 로직, 즉 코드 조각을 전달해야 한다면
메서드가 아닌 클래스를 설계하여 인스턴스를 전달해야 한다.

  1. 별도의 클래스로 설계하기
  2. 정적 중첩 클래스로 설계하기
  3. 내부 클래스로 설계하기
  4. 지역 클래스로 설계하기
  5. 익명 클래스로 설계하기

상황에 따라 위와 같은 방식으로 중첩 클래스를 사용하여 클래스 설계를 간소화할 수 있다.
중첩 클래스에 대한 설명은 아래를 참고하자.

하지만 익명클래스까지 간소화해도 인스턴스를 생성하고,
파라미터를 전달하고, 메서드를 오버라이딩하는 과정은 번거롭다.

람다를 사용하면 이러한 복잡한 과정을 생략하고 코드를 더욱 쉽게 작성할 수 있다.

함수형 인터페이스

interface FunctionInterface {
	void singleAbstractMethod();
}

함수형 인터페이스란, 하나의 추상 메서드를 가지는 인터페이스를 말한다.

람다함수형 인터페이스일 경우에만 사용할 수 있다.
클래스 상속도 불가능하며 오직 함수형 인터페이스만 구현할 수 있다.

람다는 클래스를 간단하게 작성하는 기술이지만,
하나의 함수처럼 동작하여 메서드 하나의 정보만을 전달할 수 있기 때문에
두 개 이상의 추상 메서드를 가진 인터페이스는 사용할 수 없다.

@FunctionalInterface

@FunctionalInterface
interface FunctionInterface {
	void singleAbstractMethod();
}

@FunctionalInterface함수형 인터페이스를 보장하기 위한 애노테이션이다.

위 애노테이션이 선언된 인터페이스에 메서드를 추가하면 컴파일 에러가 발생한다.
함수형 인터페이스에는 해당 애노테이션을 선언하여 관리하는 것을 권장한다.

함수형 인터페이스와 제네릭

@FunctionalInterface
interface FunctionInterface<T, R> {
	R singleAbstractMethod(T parameter);
}

함수형 인터페이스는 보통 제네릭을 사용하여 선언한다.
제네릭을 사용하지 않으면 메서드 시그니처가 다른 람다를 작성할 때마다
함수형 인터페이스를 작성해야 하는 번거로움이 발생한다.

제네릭을 사용하여 재사용성을 높이고, 타입 안전성을 확보하여 사용해야 한다.

람다

(parameter) -> { code }

익명 클래스를 설계하려면 인스턴스를 생성하고 메서드를 오버라이딩 코드를 명시해야 했다.
하지만 Java 8부터는 람다를 사용하면 번거로운 설계 코드를 생략하고

파라미터와 구현 코드 사이에 '->'으로 간단하게 작성할 수 있다.

인스턴스 생성을 위한 코드는 생성하고자 하는 변수 또는 매개변수의 타입을 통해
컴파일러가 자동으로 추론하여 생략이 가능하고
함수형 인터페이스가 오버라이딩해야 하는 메서드는 1개뿐이기 때문에
마찬가지로 메서드 이름 정보를 생략할 수 있다.

간단하게 생성했지만 일반적인 클래스와 마찬가지로 클래스도 만들어지고 인스턴스도 생성된다.
(일반적인 클래스는 클래스 정보를 '$'로 구분하지만 람다는 '$$'로 구분한다)
그렇기 때문에 인스턴스처럼 변수로도 활용할 수 있다.

  1. 변수에 대입하기
  2. 메서드 매개변수로 전달하기
  3. 메서드 결과로 반환하기

람다를 전달하면 람다로 생성된 인스턴스의 참조를 전달하게 된다.
간단하게 작성했을 뿐 실제 클래스를 설계한 것과 다를게 없다는 사실에 주목해야 한다.

뭔가 이제부터 개발자 짤을 줍줍해볼까

고차 함수

고차 함수함수 자체를 값처럼 다루는 함수를 의미한다.

즉 함수를 인자로 받거나 함수를 반환하는 메서드를 뜻한다.
자바에서는 람다를 주고받는 함수를 고차 함수라고 이해하면 되겠다.

람다 this

class Outer {
	
	private int number = 1;

	public void method() {
		
		// 익명 클래스
		Runnable runnable = new Runnable() {
			private int number = 2;
	
			@Override
			public void run() {
				// 익명클래스.this = 자기 자신
				int result = this.number;  // ** 2 **
			}
		};

		Runnable runnable = () -> {
			// 람다.this = 람다 선언 클래스(외부 클래스)
			int result = this.number;  // ** 1 **
		};
	}
}

익명 클래스 인스턴스는 내부에서 this를 호출하면 자기 자신을 가리킨다.
람다로 생성된 인스턴스는 내부에서 this를 호출할 경우,
람다를 선언한 클래스인 외부 클래스를 호출하기 때문에 주의해서 사용해야 한다.

실제로 람다는 invokeDynamic이라는 매커니즘을 사용하여 실제 클래스 파일을 생성하지 않고
(클래스는 생성되지만 클래스 파일이 만들어지지 않음)
자바가 런타임 시점에 람다 인스턴스와 람다 메서드를 각각 생성하여
생성한 람다 인스턴스가 람다 메서드를 호출하도록 동작한다.
클래스 파일을 생성하지 않아 메모리 관리가 더 효율적이고 파일 관리의 복잡성도 줄어든다.

성능 측면에서는 익명 클래스와 람다의 차이는 거의 없다.

람다 규칙

람다를 사용하기 위해서는 몇 가지 규칙을 준수해야만 한다.

메서드 시그니처

메서드 시그니처 = 메서드 반환 타입 + 메서드 이름 + 매개변수 타입과 순서

람다를 사용하려면 메서드 시그니처가 일치해야 한다.

하나의 추상 메서드를 가지는 함수형 인터페이스에만 할당하기 때문에
메서드 이름은 생략이 가능하고, 메서드 반환 타입, 매개 변수 타입과 순서만 일치시키면 된다.
매개변수의 이름은 자유롭게 작성 가능하다.

상태 관리

() -> {
	// ** 필드 선언 불가, 컴파일 에러 **
	int number = 1;
	
	..
};

람다는 클래스가 생성되고 인스턴스도 만들어지지만

함수로 여겨지기 때문에 내부에 필드와 같은 상태를 가질 수 없다.

캡처링

class Outer {

	public void method() {
		
		int changeValue = 1;

		Runnable runnable = () -> {
			//  ** 컴파일 에러 발생 **
			System.out.println(changeValue);
		}
	}
}

람다도 익명 클래스와 마찬가지로 final 또는 사실상 final 변수만 캡처해서 사용할 수 있다.

람다에서 외부 지역 변수를 사용하거나 캡처한 외부 지역 변수를 변경하면 오류가 발생한다.

람다 생략 규칙

간결하게 작성된 람다를 더욱 간결하게 작성할 수 있는 몇가지 규칙이 있다.
람다를 작성할 때에는 지금부터 소개하는 생략 규칙을 최대한 활용하는 것을 권장한다.

단일 표현식

// 기존 람다 작성 방법
(parameter) -> { .. return code }

// code가 단일 표현식인 경우
(parameter) -> code

단일 표현식이란 산술 논리 표현식, 메서드 호출, 객체 생성과 같이
하나의 값으로 평가되는 코드 조각을 의미한다.

단일 표현식은 중괄호와 return을 생략하고 더욱 간단하게 작성할 수 있다.

중괄호와 return을 함께 생략해야 하고 하나라도 포함되는 경우 두 가지 모두 작성해야 한다.
단일 표현식의 결과가 자동으로 반환 값이 된다.

매개변수 타입 추론

// 기존 람다 작성 방법
(Type parameter1, Type parameter2) -> { .. return code }

// 매개변수 타입 추론
(parameter1, parameter2) -> { .. return code }

람다는 매개변수의 타입을 생략하여 작성할 수 있다.

람다는 함수형 인터페이스에 작성된 유일한 추상 메서드를 구현하는데,
이미 인터페이스 메서드의 매개변수에 타입 정보가 담겨있기 때문에 추론이 가능하다.

매개변수 괄호 생략

// 기존 람다 작성 방법
(parameter) -> { .. return code }

// 매개변수 괄호 생략
parameter -> { .. return code }

람다에서 매개변수가 정확히 1개인 경우에 괄호를 생략할 수 있다.

매개변수가 없거나, 2개 이상인 경우에는 생략이 불가능하다.

자바 제공 함수형 인터페이스

람다는 코드 조각을 주고 받는 형태이지만, 결국 인스턴스의 참조값을 전달한다.
람다를 사용하기 위해 모든 사람들이 함수형 인터페이스를 직접 생성한다면
각자 다른 인스턴스의 참조값을 전달하여 호환이 불가능할 것이다.

이러한 함수 교환 문제를 해결하기 위해서 자바는 함수형 인터페이스를 제공하고 있다.

람다를 사용하는 경우 자바가 제공하는 함수형 인터페이스를 사용하자.

Function

@FunctionalInterface
public interface Function<T, R> {
	R apply(T t);
}

// 사용
Function<T, R> function = T parameter -> { .. return code };
R result = function.apply(parameter);

Function은 매개 변수가 있고, 반환 값이 있는 함수형 인터페이스이다.

apply() 메서드를 호출하여 사용할 수 있다.

Consumer

@FunctionalInterface
public interface Consumer<T> {
	void accept(T t);
}

// 사용
Consumer<T> consumer = T parameter -> { code };
consumer.accept(parameter);

Consumer은 매개 변수가 있고, 반환 값이 없는 함수형 인터페이스이다.

매개 변수를 소비한다는 소비자의 의미를 가지고 있다.
accept() 메서드를 호출하여 사용할 수 있다.

Supplier

@FunctionalInterface
public interface Supplier<T> {
	T get();
}

// 사용
Supplier<R> supplier = () -> { .. return code };
R result = supplier.get()

Supplier는 매개 변수가 없고, 반환 값이 있는 함수형 인터페이스이다.

결과를 공급한다는 공급자의 의미를 가지고 있다.
get() 메서드를 호출하여 사용할 수 있다.

Runnable

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

// 사용
Runnable runnable = () -> { code };
runnable.run();

Runnable은 매개 변수가 없고 반환 값이 없는 함수형 인터페이스이다.

Thread에서 사용하는 Runnable과 동일한 클래스이다.
run() 메서드를 호출하여 사용할 수 있다.

BiFunction

// 사용
BiFunction<T1, T2, R> biFunction = (T1 parameter1, T2 parameter2) -> { .. return code };
R result = biFunction.apply(parameter1, parameter2);

매개변수가 있는 자바 제공 함수형 인터페이스에서

매개변수가 2개라면 클래스 앞에 'Bi'를 붙인 함수형 인터페이스를 사용해야 한다.

각 인터페이스가 사용하는 메서드를 호출하여 사용할 수 있다.
매개변수 3개부터는 별도로 제공하는 함수형 인터페이스는 없어 직접 생성해야 한다.

Predicate

@FunctionalInterface
public interface Predicate<T> {
	boolean test(T t);
}

// 사용
Predicate<T> predicate = T parameter -> { .. return code };
boolean result = predicate.test(parameter);

Predicate는 매개변수가 있고 반환 값이 boolean인 함수형 인터페이스이다.

매개변수가 주어진 조건을 만족하는지 테스트한다는 의미를 가지고 있다.
test() 메서드를 호출하여 사용할 수 있다.

Function<T, R>으로 대체할 수 있지만,
조건을 검사한다는 목적을 분명하게 하고 코드를 더욱 간결하게 만들기 위해 사용한다.

Operator

Operator는 단항 연산인 UnaryOperator와 이항 연산인 BinaryOperator 두 가지를 제공한다.
각각 Function<T, T>, Function<T, T, T>로 대체할 수 있지만,
입력과 출력 타입이 동일한 단항 연산 또는 이항 연산을 수행한다는 목적을 분명하게 하고
코드를 더욱 간결하게 만들기 위해 사용한다.

UnaryOperator

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
	T apply(T t); // 실제 코드가 있지는 않음
}

// 사용
UnaryOperator<T> unaryOperator = T parameter -> { .. return code };
T result = unaryOperator.apply(parameter);

UnaryOperator는 매개변수 하나로 단항 연산을 수행하는 함수형 인터페이스이다.

BinaryOperator

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
	T apply(T t1, T t2); // 실제 코드가 있지는 않음
}

// 사용
BinaryOperator<T> binaryOperator = (T parameter1, T parameter2) -> { .. return code };
T result = binaryOperation.apply(parameter1, parameter2);

BinaryOperator은 두개의 매개변수로 이항 연산을 수행하는 함수형 인터페이스이다.

기본형 지원 함수형 인터페이스

// 사용
IntFunction<R> intFunction = int parameter -> { .. return code };
T result = intFunction.apply(parameter);

ToIntFunction<T> toIntFunction = T parameter -> { .. return code };
int result = toIntFunction.applyAsInt(parameter);

IntToDoubleFunction intToDoubleFunction = int parameter -> { .. return code };
double result = IntToDoubleFunction.applyAsDouble(parameter);

IntUnaryOperator intUnaryOperator = int parameter -> { .. return code };
int result = intUnaryOperator.apply(parameter);

IntConsumer intConsumer = int parameter -> { code };
intConsumer.accept(parameter);

제네릭은 기본형을 다룰 수 없어 자바는 기본형 지원 함수형 인터페이스를 별도로 제공한다.
기본형 지원 함수형 인터페이스를 사용하는 경우 오토 박싱 비용을 줄일 수 있다.

  • PrimitiveFunction : 매개변수를 Primitive 타입으로 지정
  • ToPrimitiveFunction : 반환 타입을 Primitive 타입으로 지정
  • Primitive1ToPrimitive2Function : 매개변수를 Primitive1로, 반환 타입을 Primitive2로 지정

매개변수와 반환 타입이 모두 존재하는 Function의 경우
객체 이름에 기본형(Primitive)과 함께 To를 사용하여
매개변수와 반환타입 중 기본형으로 지정할 요소를 설정할 수 있다.

그 외의 객체는 매개변수 또는 반환타입 중 한가지만 지정할 수 있거나,
매개변수와 반환 타입을 같은 타입으로 사용하는 객체이기 때문에
PrimitiveXXX 형태로 사용하면 된다.

메서드 참조

class Clazz {

	..

	// 기존 코드
	Function<T, T> function = T parameter -> method(parameter);

	// 메서드 참조 코드
	Function<T, T> function = Clazz::method;

	..

	private T method(T parameter) {
		..
	}
}

람다에서 메서드만을 호출할 때 '클래스이름::메서드이름' 형식으로 작성하면
정의되어 있는 매개변수를 자동으로 추론하여 생략한 뒤 코드를 직관적으로 작성할 수 있다.

메서드 호출 부분에 '()'가 생략된 이유는
메서드를 호출하는 것이 아닌 참조한다 의미를 나타내기 위함이다.

정적 메서드 참조

class Clazz<T> {
	
	..

	public static T method(T parameter) {
		// 코드
	}
}

// 사용
Function<T, T> function = Clazz::method  // ** 클래스이름::메서드이름 **

정적 메서드 참조는 static으로 선언된 메서드를 참조하는 방식이다.
'클래스이름::메서드이름' 형식으로 선언할 수 있다.

특정 객체의 인스턴스 메서드 참조

class Clazz<T> {

	public T method(T parameter) {
		// 코드
	}
}

// 사용
Clazz<T> clazz = new Clazz();
Function<T, T> function = clazz::method  // ** 인스턴스이름::메서드이름 **

특정 객체의 인스턴스 메서드 참조는 별도로 생성한 인스턴스의 메서드를 참조하는 방식이다.
'인스턴스이름::메서드이름' 형식으로 선언할 수 있다.

생성자 참조

class Clazz {

	private int number;

	public Clazz() {
		this.number = number;
	}
}

// 사용
Clazz clazz = new Clazz();
Function<T, T> function = Clazz::new  // ** 클래스이름::new **

생성자 참조는 생성자 메서드를 참조하는 방식이다.
'클래스이름::new' 형식으로 선언할 수 있다.

특정 타입 임의 객체의 인스턴스 메서드 참조

class Clazz<T> {

	public R method(T parameter) {
		// 코드
	}
}

// 사용
Function<Clazz, R> function = Clazz::method;

특정 타입 임의 객체의 인스턴스 메서드 참조는
매개변수로 전달한 타입 인스턴스의 메서드를 참조하는 방식이다.

정적 메서드 참조와 동일하게 '클래스이름::메서드이름' 형식으로 선언할 수 있지만
참조할 인스턴스를 매개변수로 전달한다는 점에서 차이가 있다.

실무에서 가장 자주 사용하게 된다.

마무리

람다를 알고 난 후 람다로 작성된 코드가 읽힌다는 것이 참으로 뿌듯한 것 같다.
생각보다 람다 내부에 다양한 기능이 포함되어 있어 새삼 놀랐다.

람다를 사용하기 전 먼저 익명 클래스와 람다가 필요한 상황을 구분할 줄 알아야겠다.
그리고 자바가 제공하는 함수형 인터페이스에 대해 이해하여
람다로 멋지고 깔끔하게 코드를 작성할 줄 아는 어엿한 개발자가 되어보자.

profile
복습에 대한 비판과 지적을 부탁드립니다

0개의 댓글