Dev/Java

람다식 ( Lamda expression ) / @FunctionalInterface

린네의 2024. 3. 11. 19:50

 

 

본 글에서는 기본적인 람다사용 법과 람다를 사용하는 이유에 대해 작성하고자 한다.

 

 

 

예제소스링크

 

람다식이란?

 

jdk 1.8부터 추가된 람다식은 자바를 객체지향언어인 동시에 함수형 언어로 만들었다.  람다식이란 메서드를 하나의 식(expression)으로 표현한 것이다.  메서드객체 지향에서  객체의 행위나 동작을 의미하는 용어인데, 클래스에 반드시 속해야 한다는 제약이 있다.

 

모든 메서드는 클래스에 포함되어야 하므로 클래스도 새로 만들어야하고, 객체도 새로 만들어야 하지만 람다식은 오직 람다식 자체만으로도 이 메서드의 역할을 대신할 수 있게 해준다.

 

메서드를 람다식으로 변환하면 메서드의 이름과 반환 값이 사라지게 된다.

 

다음 예제를 보면 쉽게 이해할 수 있다.

 

  •  일반적인 메서드 
int max(int a, int b)  {
   return a > b ? a : b;
}

 

 

  • 람다식
// 1
(int a, int b) -> {
	return a > b ? a : b ;
}


// 2
(int a, int b) -> a > b ? a : b


// 3
(a, b) -> a > b ? a : b

 

 

반환 값이 있는 메서드의 경우 return 문 대신  '식(expression)'으로 대신할 수 있다. 여기서 식이란 위 예제에서 'a > b? a : b' 구문을 의미한다.  

또한 매개변수의 타입도 추론이 가능할 경우 생략할 수 있다. 단,  매개변수가 두 개 이상일 경우 어느 하나의 타입만 생략하는 것은 허용되지 않는다. 

 

매개 변수가 하나뿐일 경우에는 괄호를 생략할 수 있지만, 매개변수의 타입이 있을 경우에는 괄호를 생략할 수 없다.

괄호{} 안의 문장이 하나일 때는 괄호{}를 생략할 수 있지만, 괄호{} 생략 시 ;(세미 콜론)을 붙이지 않아야 한다.  괄호 {} 안의 문장이 return 일 경우 괄호는 생략할 수 없다. 

 

한 가지 예제 코드를 작성해 봤다.

(int[] arr) -> {
	int sum = 0;
    for (int i : arr ) 
    		sum += i;
        return sum;
}

 

 

여기까지 보면 람다식이 메서드와 동등한 것 같지만 사실 람다식은 익명 클래스 객체와 동등하다.

 

 

@FunctionalInterface

 

기본적으로 객체의 메서드를 호출하려면 참조변수가 있어야 객체의 메서드를 호출할 수 있다. 이때 참조변수는 람다식과 동등한 메서드가 정의되어 있어야 하고 클래스 또는 인터페이스여야 한다. 

 

다음은 익명 객체의 메서드를 람다식으로 대체하여 호출하는 예제다.

 

  • interface 생성
interface Myfunction  {
	public abstract int max(int a, int b); // 추상 메서드 
}

 

 

  • 익명 클래스 객체 생성 
Myfunction f = new Function() {
				public int max(int a, int b) {
                		return a > b ? a : b;
                }
	};
    
    
int big = f.max(5, 3);

 

 

  • 람다식
Myfunction f = (int a, int b) -> a > b ? a: b; // 익명 객체를 람다식으로 변경 
int big = f.max(5,3);

 

 

여기서 주의해야 할 점은, max라는 함수의 선언부가 대체된 람다식과 동일해야 한다는 것이다. 즉 매개변수와 반환값이 일치해야 한다.

 

이렇게 익명 객체를 람다식으로 대체가 가능한 이유가 뭘까? 

 

람다식도 익명 객체이고, Myfunction 인터페이스를 구현한 익명 객체의 메서드 max()와 람담식의 매개변수 타입과 개수 그리고 반환값이 일치하기 때문이다.

 

결과적으로  인터페이스를 통해 람다식을 다룰 수 있고, 이렇게 람다식을 다루기 위한 인터페이스를 '함수형 인터페이스(functional interface)'라고 부른다.

 

 

함수형 인터페이스에는 오직 하나의 추상 메서드만 정의되어 있어야 한다. ( static 메서드와 default 메서드의 개수에는 제약이 없다 )  코드 작성 시 @FunctionalInterface를 추가하면 컴파일러가 함수형 인터페이스를 올바르게 정의하였는지 확인해 주는 역할을 하므로 꼭 붙이도록 하자.

 

많이 사용하는 sort 도 람다를 사용하면 간단하게 처리할 수 있다.

 

  • 람다 사용 전 
List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd");

Collections.sort(list, new Camparator<String>() {
		public int compare(String s1, String s2) {
        	return s2.compareTo(s1);
        }
});

 

 

  • 람다 사용 후 
List<String> list = Arrays.asList("abc", "aaa", "bbb", "ddd");

Collections.sort(list, (s1, s2) -> s2.compareTo(s1));

 

 

만약 메서드의 반환 타입이 함수형 인터페이스라면  이 함수형 인터페이스의 추상메서드와 동등한 람다식을 가리키는 참조변수를 반환하거나 람다식을 직접 반환할 수 있다.

 

  • 람다식을 직접 반환하는 예제 
@FunctionalInterface
interface MyFunction  {
	void myMethod(); // 추상 메서드 
}

MyFunction myMethod() {
	MyFunction f = () -> {};
        return f;  // 또는 return () -> {}; 와 같이 표현할 수 있음 
}

 

 

람다식을 참조변수로 다를 수 있다는 것은 변수처럼 메서드를 주고받는 것이 가능해진 것을 의미한다.   이렇게 되면 코드가 더 간결하고 이해하기 쉬워진다. 

 

 

람다식의 타입과 형 변환

함수형 인터페이스로 람다식을 참조할 수 있지만,  기본적으로 람다식은 익명 객체이다.  익명 객체는 타입이 없다.

 

대입 연산자의 양변의 타입을 일치시키기 위해 아래와 같이 형변환이 필요하다. 이 형변환은 생략이 가능하다.

 

람다식은 객체임이 분명하지만, Object로 형변환이 불가능하다. 오직 함수형 인터페이스로만 형변환이 가능하다. 만약 굳이 형변환을 하고 싶다면 함수형 인터페이스로 변환한 뒤 Object 타입으로 다시 변환해야 한다. 

 

 

 

외부 변수를 참조하는 람다식

람다식은 익명 객체다. 따라서 람다식에서 외부에 선언된 변수에 접근하는 규칙은 익명클래스와 동일하다.

 

  • 람다식 내에서 참조하는 지역 변수는 final 이 붙지 않았어도 상수로 간주되므로 람다식 내에서 또는 다른 곳에서 변수의 값을 변경할 수 없다.   
  • 외부 지역 변수와 같은 이름의 람다식 매개변수는 허용되지 않는다.
@FunctionalInterface
interface MyFunction {
	void myMethod();
}

...


void method(int i) {  // 람다식 내에서 참조 되었으므로 final int i 와 동일함 
	int val = 30; // 람다식 내에서 참조 되었으므로 final int val = 30; 과 동일함
    i = 10; // 에러발생 - 상수의 값을 변경할 수 없음 
    
    MyFunction f = (i) -> { // 에러발생 - 외부 지역변수와 이름이 중복됨
    		System.out.println(" i :" + i );
    	    System.out.println(" val :" + val);
    };

}

 

 

자주 사용하는 함수형 인터페이스 ( java.util.function)

 

java.lang.Runnable void run() 매개변수도 없고, 반환값도 없음
Supplier<T> T get() 매개변수는 없고, 반환값만 있음
Consumer<T> void accept(T t) 매개변수만 있고, 반환값이 없음
Function<T,R> R apply(T t) 일반적인 함수로 하나의 매개변수를 받아 결과를 반환함
Predicate<T> boolean test(T t)  조건식을 표현하는데 사용 됨. 매개변수는 하나이며 변환 타입은 boolean 이다,

 

  • function 예제 
Fuction<Integer, Integer> sample = s -> s + 100;

int s = 0;

System.out.println( smaple.plus(s) ) ;

 

  • predicate 예제 

predicate는 조건식을 람다식으로 표현할 때 많이 사용한다.

 

Predicate<String> isEmptyStr = s -> s.length() == 0; // return 이 boolean 으로 반환 된다.

String s = "";
if(isEmptyStr.test(s)) System.out.println("This is an empty String");

 

 

매개변수가 두 개인 함수형 인터페이스의 경우 이름 앞에 접두사 Bi 가 붙는다.

 

BiConsumer<T, U> void accept(T t, U u) 두개의 매개변수만 있고 반환값이 없다.
BiFunction<T,U,R> R apply(T t, U u) 일반적인 함수로 두개 매개변수를 받아 결과를 반환함
Predicate<T, U> boolean test(T t, U u)  조건식을 표현하는데 사용 됨. 매개변수는 두개 이며 변환 타입은 boolean 이다,

 

 

매개변수가 두 개 이상 필요하다면 직접 만들어 사용하면 된다.

 

 

  • 매개변수가 두 개 이상일 경우 함수형 인터페이스 선언 예제
@FunctionalInterfcae
interface TriFunction<T, U, V, R> {
	R apply(T t, U u, V v);
}

 

 

Function의 또 다른 변형으로 UnaryOperator와  BinaryOperator 가 있는데 매개변수의 타입과 반환 타입의 타입이 모두 일치한다는 점만 제외하고 Function과 동일하다.

 

 

UnaryOperator<T> T apply(T t) Function 과 동일하지만 매개변수 타입과 결과 타입이 동일하다
BinaryOperator<T> T apply(T t, T t) BiFunction 과 동일하지만 매개변수와 결과의 타입이 같다.

 

 

아래는 기본적인 함수형 인터페이스를 사용하는 예제다.

 

class LamdaEx {
	public static void main(String[] args {
    	
        Supplier<Integer> s = () -> (int) (Math.random()*100) + 1;  // 매개변수는 없지만 결과 값만 있다
        Consumer<Integer> c = i -> System.out.print(i=", "); // 매개변수는 있지만 결과 값은 없다
        Predicate<Integer> p = i -> i%2 == 0; // 매개변수가 있고 결과가 boolean 으로 리턴 된다
        Function<Integer, Integer> f = i -> i / 10*10; // 매개변수와 결과값이 모두 있다.
    
    	List<Integer> list = new ArrayList<>();
        makeRandomList(s, list);
        System.out.println(list);  // 랜덤 값으로 생성된 10개의 리스트가 생성되어 출력 됨 
        printEventNum(p, c, list); // [20, 80, 16, 46, ]
    	List<Integer> newList = doSomething(f, list);
        System.out.println(newList); // 
    
    
    }
    
 
 	static <T> void makeRandomList(Supplier<T> s, List<T> list) {
    	for(int i=0; i<10; i++) {
        	list.add(s.get()); // 
        }
    
    }
 
 	static <T>  void printEventNum(Predicate<T> p, Cosumer<T> c, List<T> list) {
    	System.out.println("[");
        
        for(T i : list) {
        		if(p.test(i))  // 반환값이 boolean
                	c.accept(i); // 매개변수는 있지만 반환 값은 없음 
        		
        }
        
        System.out.println("]");
        
    
    }
 
    
    static <T> List<T> doSomething ( Function<T,T> f, List<T> list) {
    	
        List<T> newList = new ArrayList<T>(list.size());
        for(T i : list) {
        		newList.add(f.apply(i));
        
        }
        
        return newList;
    	
    }

}

 

 

 

기본형을 사용하는 함수형 인터페이스

일반적으로 지네릭 타입을 사용하게 되면 기본형 타입의 값을 처리할 때도  래퍼 클래스를 사용하게 된다. 그러나 기본형 대신 래퍼 클래스를 사용하는 것은 비효율 적이므로, 기본형을 사용하는 함수형 인터페이스들이 제공된다. ( 개인적으로 생각하기에는  요즘에 하드웨어 성능이 워낙 좋아져서 사실 래퍼를 쓰든 기본형을 쓰든 큰 차이는 없는 것 같다 )

 

DoubleToIntFunction int applyAsInt(double d)  AtoBFunction은 입력이 A 타입
출력이 B 타입
ToIntFunction<T> int applyAsInt(T value) ToBFunction 은 출력이 B 타입
입력은 지네릭 타입
IntFunction<R> R apply(T t, U u) AFunction 은 입력이 A  타입이고 출력은 지네릭 타입
ObjintConsumer<T> void accept(T t, U u) ObjAFunction은 입력이 T,A 타입이고 출력은 없다

 

Function의 합성과 Predicate의 결합

두 람다식을 합성해서 새로운 람다식을 만들 수 있다.  

 

 

  • function의 합성

function을 합성하는 방법은 andThen, compose 가 있다. 간단한 사용 예제는 다음과 같다.

Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, String> g = (i) -> Integer.toBinaryString(i);

Function<String, String> h = f.andThen(g); // f 를 먼저 적용하고 g 를 적용한다


Function<Integer, String> g = (i) -> Integer.toBinaryString(i);
Function<String, Integer> f = (s) -> Integer.parseInt(s, 16);
Function<Integer, Integer> h = f.compose(g); // g 를 먼저 적용하고 f 를 적용한다.

 

identify()는 함수를 적용하기 이전과 이후가 동일한 항등함수가 필요할 때 사용한다. 

 

Function<String, String> f = x -> x;

Function<String, String> f = Fuction.identify(;

 

위의 두 문장은 동일하다. 항등함수는 잘 사용하지 않는다.

 

 

 

  • predicate의 결합

여러 predicate를 and(), or(), negate()로 연결해서 하나의 새로운 Predicate로 결합할 수 있다.

 

Predicate<Integer> p = i -> i < 100;
Predicate<Integer> q = i -> i < 200;
Predicate<Integer> r = i -> i % 2 == 0;

Predicate<Integer> notP = p.negate(); // i >= 100

Predicate<Integer> all = notP.and(q.or(r));  // 100 <= i && (i < 200 || i % 2 == 0 )
System.out.printlnt(all.test(150)); // true

 

 

static isEqual() 메서드는 두 대상을 비교하는 Predicate를 생성한다.

 

boolean result = Predicate.isEqual(str1).test(str2);

 

 

메서드 참조,  람다식을 보다 간단하게 표현하는 방법

람다식이 하나의 메서드만 호출하는 경우에는 메서드 참조(method Reference)라는 방법으로 람다식을 간략히 할 수 있다.

'클래스이름::메서드이름' 또는 '참조변수::메서드이름' 꼴로 변환하면 된다. 

 

메서드 참조를 이용하면 람다식을 마치 static 변수처럼 다룰 수 있게 해주는 이점이 있다.

 

// 예제 1
Function<String, Integer> f = (String s) -> Integer.parseInt(s);
Function<String, Integer> f = Integer::parseInt; // 메서드 참조

// 예제 2
BiFunction<String, String, Boolean> f = (s1, s2) -> s1.equals(s2);
BiFunction<String, String, Boolean> f = String::equals; // 메서드 참조

//예제 3
Myclass obj = new MyClass();
Function<String, Boolean> f = (x) -> obj.equals(x); // 람다식 
Function<String, Boolean> f2 = obj::equals; // 메서드 참조

 

종류 람다식 메서드참조
static 에서의 메서드 참조 (x) -> ClassName.method(x) ClassName::method
인스턴스에서 메서드 참조 (obj, x) -> obj.method(x) ClassName::method
특정 객체 인스턴스에서 메서드 참조 (x) -> obj.method(x) obj::method

 

 

생성자를 호출하는 람다식도 메서드 참조로 변환할 수 있다.

 

// 예제 1
Supplier<MyClass> s = () -> new MyClass(); // 람다식
Supplier<MyClass> s = MyClass::new; // 메서드 참조

// 예제 2
BiFunction<Integer, String, MyClass> bf = (i,s) -> new MyClass(i, s); // 람다식 
BiFunction<Integer, String, Myclass> bf = MyClass::new;  // 메서드 참조


// 예제 3 - 배열생성
Function<Integer, int []> f = x -> new int[x]; // 람다식
Function<Integer, int []> f2 = int[]::new; // 메서드 참조

 

 

 

 

왜 람다를 사용해야 하는가?

람다를 사용하면 다음과 같은 이점을 얻을 수 있다.

 

  • 간결함 : 람다를 사용하면 함수명과 매개변수가 제거되어 간결하고 직관적으로 작성할 수 있다. 
  • 익명 함수로 사용할 수 있음 :  메서드를 사용하기 위해 필요한 객체의 생성이 생략되면서 따로 정의하지 않고 필요한 곳에서 바로 선언하여 사용할 수 있다.
  • 코드의 유연성을 높임 : 람다식을 사용하면 람다식 자체를 매개변수로 넘기거나 반환할 수 있다. 
  • 병렬 처리 : 코드가 간결해짐에 따라 병렬 처리를 위해 별도의 스레드나 작업을 생성하고 관리하는 부담이 줄고, CPU 코어를 효과적으로 활용하여 작업을 분산시킬 수 있다. 또한 동기화나 락을 사용하여 공유 자원에 대한 안전한 접근을 보장한다.