
람다식, JDK 1.8부터 추가된 기능으로 자바를 함수형 언어로 되게 만들어준 기능이다.
자바를 처음 접했을 때 별로 중요해보이지 않아 깊이 공부하지 않고 넘어갔었는데, 람다식으로 인한 함수형 언어로서의 장점을 버릴 수 없다 생각하여 이번 기회에 제대로 공부해보고자 한다.
람다식을 공부하기 시작할 때 솔직히 많이 헷갈렸다. 함수형 인터페이스는 뭐고,,,,java.util.function 패키지는 또 뭐고....
이 키워드들 위주로 기억하면서 공부하자. 익명 객체 역할, 익명 함수
익명 객체? 익명 클래스가 혹시 기억나는가?
쓰레드를 공부할 때도 예시에서 사용한 적이 있었다.
자, 복습할 겸 간단한 쓰레드를 만들어보자.
public class Main {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start(); //This is MyThread
}
}
class MyThread extends Thread{
public void run(){
System.out.println("This is MyThread");
}
}
기본적인 쓰레드 형태이다. 근데 나는 클래스를 따로 만드는 것마저 귀찮다. 이럴 때는 익명 클래스로 작성해도 된다.
public class Main {
public static void main(String[] args) {
Thread myThread = new Thread(){
public void run(){
System.out.println("This is MyThread");
}
};
myThread.start();
}
}
쓰레드를 예로 든거지만 다른 익명 클래스도 작성하는 법은 똑같다. 익명 클래스는 말 그대로 이름 없는 클래스이기 때문에 일회용이라고 생각하면 된다.
클래스의 선언과 객체 생성을 동시에 하기 때문에 단 한번만 사용될 수 있고 오직 하나의 객체만을 생성할 수 있는 일회용 클래스이다.
이 익명 클래스의 장점 중 하나는 인터페이스를 바로 구현할 수 있다.
public class Main {
public static void main(String[] args) {
MyInterface myInterface = new MyClass();
myInterface.MyMethod(); //클래스를 생성하여 인터페이스 구현
}
}
interface MyInterface{
public void MyMethod();
}
class MyClass implements MyInterface{
@Override
public void MyMethod() {
System.out.println("클래스를 생성하여 인터페이스 구현");
}
}
다형성이 생각나는 예제인데, 추상체와 구현체를 만들어서 어떤 클래스를 구현하느냐에 따라 실행되는 구현 메서드가 달라진다.(추상체만으로 메서드를 호출하면 NullPointerException이 발생한다)
이 코드를 익명 클래스를 사용해서 바꿔보겠다.
public class Main {
public static void main(String[] args) {
MyInterface myInterface = new MyInterface() {
@Override
public void MyMethod() {
System.out.println("익명 클래스를 사용하여 인터페이스 구현");
}
};
myInterface.MyMethod(); //익명 클래스를 사용하여 인터페이스 구현
}
}
interface MyInterface{
public void MyMethod();
}
훨씬 간결해지지 않았는가? 하지만 이렇게 익명 객체로 구현한 경우에는 오로지 단 하나의 인터페이스만을 구현할 수 있다.
그럼 본론으로 돌아와서, 람다식이란 무엇이고 익명 클래스는 왜 설명한건가?
람다식은 간단히 말하자면 메서드를 하나의 식으로 표현한 것이라고 생각하면 된다. 자바를 처음 배울 때 어떠한 메서드를 호출하려면 클래스를 만들고 -> 그 안에 메서드를 만들고 -> 클래스의 인스턴스를 생성하고 -> 메서드를 호출하는 과정을 겪어야 했다.
하지만, 이 모든 과정없이 람다식 하나만으로 메서드 역할을 대신할 수 있다.
그럼 람다식의 작성방법을 알아보자.
반환타입 메서드명(매개변수){
//내용
}
기존의 메서드 형태는 위처럼 선언부에 반환타입과 메서드명, 그리고 매개변수가 있을 경우 입력해주고 중괄호 안에는 구현부로써 내용을 입력하였다.
(매개변수) -> { //내용 }
람다식은 선언부의 반환타입과 메서드명을 없애고 ->를 추가해주면 된다.
- return문인 경우 중괄호를 생략할 수 없음
- return문 대신 식으로 표현할 경우 중괄호 생략
(int a, int b) -> {return a > b ? a : b;};
return을 사용하여 반환을 할 경우 이렇게 중괄호를 반드시 적어줘야 한다. 하지만, return 없이 식만으로 표현할 경우 연산결과가 자동적으로 반환값이 되며 이때는 중괄호를 생략해야 한다.
(int a, int b) -> a > b ? a : b;
매개변수의 타입 또한 추론이 가능한 경우 생략이 가능한데, 대부분 생략이 가능하다.
(a, b) -> a > b ? a : b;
람다식은 어떻게 작성하는지 알게 되었고 이제 왜 익명 클래스를 앞에서 언급했는지 설명하겠다.
람다식은 어떤 클래스에 포함되어 있지 않고 익명 클래스의 객체라고 생각해야 한다.
그렇다면 익명 객체인 람다식을 만들려면 무엇이 필요할까? 구현해야 하는 추상체인 인터페이스가 필요하다.
interface MyFunctionInterface{
public void MyMethod();
}
여기 추상메서드를 가지고 있는 MyFunctionInterface 인터페이스가 있다. 이제 이 인터페이스를 구현하고자 한다. 방법은 2가지가 있다.
지금은 MyFunctionInterface를 구현하는 클래스가 없기 때문에 2번째 방법을 사용해보자.
MyFunctionInterface myFunctionInterface = new MyFunctionInterface() {
@Override
public void MyMethod() {
System.out.println("익명 클래스로 인터페이스 구현");
}
};
myFunctionInterface.MyMethod();
앞에서도 설명했지만 익명 클래스를 사용하면 별도의 클래스 없이 추상체를 구현할 수 있다.
그런데 람다식을 사용하면 더 간결하게 표현이 가능하다. 람다식은 익명 클래스의 객체와 동등하다고 했었는데, 말 그대로 익명 클래스 대신 사용하면 되는 것이다.
람다식으로 표현해보겠다.
MyFunctionInterface myFunctionInterface = () -> {
System.out.println("람다식으로 인터페이스 구현");
};
myFunctionInterface.MyMethod();
코드가 훨씬 간결해졌다.
그렇다면 왜 이게 가능한 것일까? MyFunctionInterface를 구현한 익명 객체의 메서드 MyMethod()와 람다식의 매개변수 타입과 개수, 반환값이 일치하기 때문에 가능한 것이다.
하지만, 람다식을 사용하여 구현할 경우 구현하는 인터페이스의 추상 메서드는 오직 하나만 정의되어 있어야 한다. 즉, 람다식을 다루기 위한 인터페이스는 오직 람다식을 위해 작성하며, 이러한 인터페이스를 함수형 인터페이스라고 한다.
람다식은 객체를 주고 받는 것이기 때문에 객체처럼 활용이 가능하다. 예를 들어, 매개변수 타입이 함수형 인터페이스 타입인 경우 객체 대신 람다식으로 입력이 가능하다.
public class Main {
static void execute(MyFunctionInterface f){
f.run();
}
static MyFunctionInterface getFunction(){
MyFunctionInterface f2 = () -> System.out.println("f2");
return f2;
}
public static void main(String[] args) {
MyFunctionInterface f1 = () -> System.out.println("f1");
execute(f1); //f1
MyFunctionInterface f2 = getFunction();
execute(f2); //f2
execute(()-> System.out.println("f3")); //f3
}
}
interface MyFunctionInterface{
void run();
}
함수형 인터페이스 MyFunctionInterface이 정의되어 있고, 클래스 메서드로 execute()와 getFunction()가 있다.
execute()는 단순 실행 기능이고 매개변수에 어떤 객체가 들어오냐에 따라 다른 결과를 출력한다. getFunction()은 객체를 만들어서 반환하는 기능을 한다.
main 메서드의 첫 번째는 람다식으로 추상체를 구현하여 이를 execute()에 입력하여 실행시켰고, 두 번째의 경우 클래스 메서드인 getFunction()을 사용하여 객체를 입력하였다.
마지막에는 매개변수로서 객체 대신 람다식을 직접 입력하였는데 정상적으로 출력이 되었다.
익명 객체는 타입이 없기 때문에 함수형 인터페이스와 타입이 일치하는 것은 아니다.
하지만, 대입 연산자를 통해 참조변수에 람다식을 대입해야 하기 때문에 다음과 같은 형변환이 이루어져야 한다.
MyFunctionInterface f =(MyFunctionInterface) () -> System.out.println("f");
람다식은 MyFunctionInterface를 직접 구현하지는 않지만, 이 인터페이스를 구현한 클래스의 객체와 완전히 동일하기 때문에 위와 같은 형변환을 허용하며, 이 형변환은 생략이 가능하다. 그래서 별도로 형변환을 명시하지 않아도 컴파일 에러가 발생하지 않는다.
함수형 인터페이스로 형변환을 허용한다고 해서 다른 타입으로 형변환이 가능하다는 것은 아니다. 오직 함수형 인터페이스 타입으로만 형변환이 가능하며, 다른 타입으로 형변환하기 위해서는 함수형 인터페이스 타입으로 형변환을 먼저 해야 한다.
위의 코드를 Object 타입과 String 타입으로 형변환해보자.
Object obj =(Object)(MyFunctionInterface)(() -> System.out.println("f"));
String str = ((Object)(MyFunctionInterface)(() -> System.out.println("f"))).toString();
대부분의 메서드들이 매개변수가 없거나 1,2개 또는 반환값이 없거나 있으며 지네릭 메서드로 정의할 경우 매개변수 또는 반환값의 타입이 달라도 상관없다.
이처럼 굳이 자주 쓰는 메서드 형식을 람다식으로 만들려고 일일히 새로운 함수형 인터페이스를 만드는 것은 비효율적이다. 이미 만들어져 있는 형식이 있으면 그 형식을 재사용하는 것이 더 좋은 방법이다.
java.util.function 패키지에는 일반적으로 자주 쓰이는 형식의 메서드를 함수형 메서드로 미리 정의해 놓아서, 이 패키지를 import하여 람다식을 사용할 수 있다.
| 인터페이스 타입 | 메서드명 | 기능 |
|---|---|---|
| Supplier< T > | T get() | 매개변수 x, 반환값 o |
| Consumer< T > | void accept(T t) | 매개변수 o, 반환값 x |
| Function<T,R> | R apply(T t) | 매개변수 o, 반환값 o |
| Predicate< T > | boolean test(T t) | 매개변수 o, 반환값 boolean |
지네릭 타입 메서드이기 때문에 원시타입(primitive type)은 입력할 수 없고 래퍼 클래스를 사용해야 한다.
Predicate<T>가 있는 이유가 바로 원시타입인 boolean을 Function<T,R>에 입력할 수 없기 때문이다.
예제를 통해 사용법을 알아보자.
Supplier<String> s = ()->{
return "Supplier";
};
Consumer<String> c = (str)->{
System.out.println(str);
};
Function<String,String> f = (str)->{
return str;
};
Predicate<String> p = (str)->{
return str == "Predicate";
};
System.out.println(s.get());
c.accept("Consumer");
System.out.println(f.apply("Function"));
System.out.println(p.test("Predicate"));
Supplier
Consumer
Function
true
사용방법은 간단하다. 사용하려는 메서드가 해당하는 타입의 참조변수를 선언하고 그 메서드 형식에 맞게 람다식을 대입하면 된다.
java.util.function 패키지를 사용하면 이렇게 내가 만드려는 메서드의 형식과 같은 인터페이스 타입을 가져와 람다식을 넣고 참조변수.해당 메서드로 호출하면 된다.
그럼 매개변수가 2개일 때는 어떻게 해야 할까? 매개변수가 2개인 함수형 인터페이스도 이미 정의되어 있다.
| 인터페이스 타입 | 메서드명 | 기능 |
|---|---|---|
| BiConsumer< T, U > | void accept(T t, U u) | 매개변수 o, 반환값 x |
| BiFunction<T,U,R> | R apply(T t, U u) | 매개변수 o, 반환값 o |
| BiPredicate< T,U > | boolean test(T t, U u) | 매개변수 o, 반환값 boolean |
2개라는 의미로 Bi라는 글자가 추가되었고 메서드명은 동일하다. Supplier<T>의 경우 매개변수가 없기 때문에 별도로 존재하지 않는다.
BiFunction<Integer,Integer,Integer> bf = (a,b)->{
if(a>b)
return a;
else return b;
};
System.out.println(bf.apply(2,3)); //3
}
매개변수의 대소관계를 비교해 큰 값을 반환하는 함수를 만들어보았다.
매개변수가 3개일 경우에는 어떻게 해야할까? 아쉽게도 3개부터는 별도로 정의되어 있지 않아 이러한 경우에는 함수형 인터페이스를 직접 만들어야 한다.
Function<T>를 변형한 것으로 UnaryOperator<T>와 BinaryOperator<T,T>가 있다. 각각 입력 매개변수가 1개, 2개이고 Function<T>와 차이점은 T가 매개변수 타입과 반환값 타입으로 동일하게 입력된다는 것이다.
| 인터페이스 타입 | 메서드명 | 기능 |
|---|---|---|
| UnaryOperator< T > | T apply(T t) | Function의 자손 |
| BinaryOperator< T > | T apply(T t, T t) | BiFunction의 자손 |
위에서 보여준 BiFunction<T,U,R> 예제의 경우 반환값과 매개변수 타입이 전부 같기 때문에 BinaryOperator<T>를 사용하는 것이 더 효율적이다.
BinaryOperator<Integer> bo = (a,b)->{
if(a>b)
return a;
else return b;
};
System.out.println(bo.apply(2,3));
앞서 언급한 추상 메서드들은 전부 제네릭 타입 메서드였는데 원시타입을 사용할 수 없었다. 원시 타입 값을 처리할 경우에는 래퍼 클래스를 사용했었는데, AutoBoxing & UnBoxing으로 인해 더 비효율적이다.
효율적 처리를 위해서는 원시타입을 사용하는 함수형 인터페이스를 사용하는 것이 좋다.
| 인터페이스 타입 | 메서드명 | 기능 |
|---|---|---|
| AToBFunction | B applyAsB(A a) | 입력이 A타입, 반환이 B타입 |
| ToBFunction< T > | B applyAsB(T t) | 입력이 제네릭 타입, 출력이 B타입 |
| AFunction< R > | R apply(T t, U u) | 입력이 A 타입, 출력이 제네릭 타입 |
| ObjAConsumer< T > | void accept(T t, U u) | 입력이 T, A타입 |
A,B에는 원시타입 T,R에는 제네릭 타입이 들어가며 자세한 예시는 자바 API 공식 문서를 참고하자.
https://docs.oracle.com/javase/8/docs/api/index.html?java/util/function/package-summary.html
원래는 인터페이스에 추상 메서드만 정의가 가능했지만 JDK 1.8부터 static,default 메서드도 추가할 수 있다고 이전에 인터페이스 공부 때 언급했었다.
앞에서 본 apply,test들은 추상 메서드에 해당하며 사용하려면 람다식으로 구현해야 한다. 하지만 static, default 메서드들은 구현 없이 사용 가능하다.
https://docs.oracle.com/javase/8/docs/api/index.html?java/util/function/package-summary.html
자바 공식문서에서 모든 function 패키지 인터페이스의 메서드를 확인할 수 있지만 너무 많으므로 Function<T,R>과 Predicate< T >만 알아보겠다.
Function<T,R>의 default 메서드
| 메서드명 | 기능 |
|---|---|
| andThen() | 호출한 함수 먼저 적용 후 괄호 안에 함수를 적용 |
| compose() | 위의 기능과 정반대의 기능을 함 |
합성함수를 만들 수 있는 메서드들인데 두 함수 f(x), g(x)가 있을 때 g(f(x))를 만들고 싶다면 f.andThen(g)를, f(g(x))를 만들고 싶다면 f.compose(g)를 호출하면 된다.
간단한 예제를 통해 이해해보자.
Function<Integer,String> f = i -> String.valueOf(i*5);
Function<String,Integer> g = s -> Integer.parseInt(s);
Function<Integer,Integer> h = f.andThen(g); //g(f(x))
Function<String,String> q = f.compose(g); //f(g(x))
System.out.println(h.apply(10));
System.out.println(q.apply("10"));
f와 g의 제네릭 타입이 다르지만 입력과 출력 타입이 맞기 때문에 문제가 없다. 그리고 결과로 나온 합성함수의 타입은 가장 먼저 적용된 함수의 입력값과 마지막 함수의 반환값을 타입으로 입력하면 된다.
| 메서드명 | 기능 |
|---|---|
| and() | 논리연산자 &&(and)와 같음 |
| or() | 논리연산자 |
| negate() | 논리연산자 !(not)와 같음 |
이것들에 대해서는 따로 설명이 필요없으니 예제만 보고 넘어가자.
Predicate<Integer> p = i -> i > 50;
Predicate<Integer> q = i -> i < 100;
Predicate<Integer> r = i -> i%2 == 0;
Predicate<Integer> k = r.or(p.and(q));
System.out.println(k.test(57));
r 또는 (p이고 q)를 구현해보았는데 57은 2의 배수는 아니지만 50과 100사이에 있으므로 결과값이 true가 된다.
람다식을 더욱 간결하게 표현할 수 있는 방법이 있다. 메서드 참조라는 방법을 사용하는 것인데, 람다식이 오직 단 하나의 메서드만 호출할 때에만 사용 가능하다.
Function<String,Integer> f = s -> s.length();
이와 같이 String 클래스의 인스턴스 메서드인 lengh() 하나만을 호출할 경우 메서드 참조를 통해 다음과 같이 표현이 가능하다.
Function<String,Integer> f = String::length;
이렇게 간략하게 표현이 가능한 것은 매개변수의 개수와 타입이 같고 제네릭 타입으로 유추가 가능하기 때문이다.
메서드 호출 외 추가적인 작업이 필요할 경우에는 이와 같은 표현이 불가하고 람다식으로 작성해야 한다.
메서드 참조는 3가지 경우로 나뉘는데, static 메서드를 참조하거나 인스턴스 메서드를 참조 또는 특정 객체의 인스턴스 메서드를 참조하는 것이다.
말 그대로 static 메서드를 호출할 경우인데 클래스명::메서드명으로 입력하면 된다.
Function<Integer,String> f = i -> String.valueOf(i);
valueOf(int i)는 String 클래스의 static 메서드이고 int 타입을 매개변수로 받아 String 타입으로 반환한다. 이제 이 코드를 간략하게 표현하겠다.
//Function<Integer,String> f = i -> String.valueOf(i);
Function<Integer,String> f = String::valueOf;
제네릭 타입을 보고 매개변수와 반환 타입을 유추가능하기 때문에 이처럼 간략화가 가능하다.
위와 마찬가지로 클래스명::메서드명을 입력하여 사용하며, 말 그대로 클래스의 인스턴스 메서드를 호출할 경우 사용한다.
String 클래스를 예로 들자면, length()라는 메서드가 인스턴스 메서드에 해당하는데 String 값을 입력받아 그 길이를 int값으로 반환하는 기능이다.
Function<String,Integer> f = s -> s.length();
매개변수가 String 타입 1개이고 이를 int 타입으로 반환하므로 이를 간략화하면
Function<String,Integer> f = String::length;
람다식에서도 입력받는 매개변수가 1개이고 선언타입의 제네릭 타입도 String 타입 1개이기 때문에 생략을 하는 것이 가능한 것이다. 또한, 반환타입도 문자열의 길이가 int타입으로 반환되기 때문에 제네릭 타입도 이에 맞추어 입력해야 한다.
인스턴스 메서드를 참조하는 것과 비슷하지만 다르다. 위와 같이 인스턴스 메서드를 참조할 경우 입력값에 따라 반환값이 달라지지만, 특정 객체의 인스턴스 메서드를 참조할 경우 반환값이 입력값이 아닌 객체에 대하여 출력된다.
예시를 보자.
String str = new String("Hello");
Function<String,Integer> f = String::length;
Supplier<Integer> s = str::length;
System.out.println(f.apply("Hello")); // 5
System.out.println(s.get()); // 5
String 타입의 객체인 str의 length()를 참조하면 str에 대한 문자열 길이가 출력된다. 반대로 String 클래스의 인스턴스 메서드인 length()를 참조하면 입력값에 따라 반환값이 달라진다.
특정 객체의 인스턴스 메서드를 참조할 경우 참조변수명::인스턴스 메서드명 으로 사용하면 된다.
예시에서 Supplier를 사용한 이유는 입력값과 무관하게 반환되기 때문에 Function의 제네릭 타입으로 유추가 안되어 간략화가 불가능하다. 그래서 입력값은 받지 않고 반환값만 있는 Supplier를 사용한 것이다.
Function<String,Integer> f = (s)->str.length();
물론 이렇게 람다식으로 작성하면 입력매개변수를 받게 만들 수는 있지만 반환값은 무관하다.
인스턴스를 생성하는 new 연산자도 메서드 참조가 가능하다.
생성하려고 하는 클래스명과 new 키워드를 사용하여 클래스명::new로 참조가 가능하다.
생성자의 매개변수 유무와 개수에 따라 선언타입을 다르게 해주어야 한다.
매개변수가 없는 생성자의 경우 Supplier, 매개변수가 1개인 경우 Function로 선언해야 하고 반환타입은 당연히 생성하려는 인스턴스의 클래스 타입으로 입력해야 한다.
public class Main {
public static void main(String[] args) {
Supplier<exClass> s = exClass::new;
Function<Integer,exClass> f = exClass::new;
Function<String,exClass> f2 = exClass::new; //입력매개변수 타입에 따라 제네릭 타입을 입력
BiFunction<Integer,String,exClass> bf = exClass::new;
}
}
class exClass{
int a;
String b;
public exClass() {
} //매개변수가 없는 생성자
public exClass(int a) {
this.a = a;
} //매개변수가 1개인 생성자
public exClass(String b) {
this.b = b;
}
public exClass(int a, String b) {
this.a = a;
this.b = b;
} //매개변수가 2개인 생성자
}
배열도 마찬가지로 생성자 메서드 참조가 가능하다.
크기가 x인 int 배열이 있다고 가정하자.
// Function<Integer,int[]> f = (x)-> new int[x];
Function<Integer,int[]> f = int[]::new;
int[] intArray = f.apply(5);
System.out.println(intArray.length); // 5
매개변수로 x 하나만을 받기 때문에 생략하여 예시처럼 간략화가 가능하다.