[Java 8] 함수형 인터페이스와 람다
함수형 인터페이스와 람다
- 함수형 인터페이스와 람다표현식 소개
- 자바에서 제공하는 함수형 인터페이스
- 람다 표현식
- 메서드 레퍼런스
📌 함수형 인터페이스와 람다 표현식 소개
함수형 인터페이스(Functional Interface)
- 추상 메서드를 딱 하나만 가지고 있는 인터페이스
- SAM (Single Abstract Method) 인터페이스
- @FuncationInterface 애노테이션을 가지고 있는 인터페이스
@FunctionalInterface
public interface RunSomething {
void doIt();
static void printName() {
System.out.println("name");
};
default void printAge() {
System.out.println(30);
}
}
- @FunctionalInterface : 해당 인터페이스가 함수형 인터페이스임을 명시하는 어노테이션이다. 함수형 인터페이스 규칙을 위반하면 컴파일 에러가 발생해서 더 견고하게 관리할 수 있다.
- 위 코드를 보면 추상메서드인 doIt() 메서드와 printName()과 printAge() 메서드, 총 3개의 메서드가 있는데도 해당 인터페이스는 함수형 인터페이스(Functional Interface)이다.
static 메서드와 default 메서드가 유무와 상관없이 하나의 추상메서드만 존재하기 때문이다.
참고 : interface에서 해당 메서드가 추상메서드라고 명시하는 abstract는 생략가능하다.
또한 static 메서드에서 public 접근제어자도 생략 가능하다.
자바 8 이전 → 익명 내부 클래스 사용
public class Foo {
public static void main(String[] args) {
// 익명 내부 클래스 annoymous inner class
RunSomething runSomething = new RunSomething() {
@Override
public void doIt() {
System.out.println("Hello");
}
};
}
}
자바 8 이후
람다 표현식을 이용하여 간략하게 구현할 수 있다.
public class Foo {
public static void main(String[] args) {
RunSomething runSomething = () -> System.out.println("Hello");
}
}
만약 함수 내에서 처리해야 하는 일이 하나가 아니라면 다음과 같이 사용한다.
public class Foo {
public static void main(String[] args) {
RunSomething runSomething = () -> {
System.out.println("Hello");
System.out.println("Lambda");
};
}
}
람다 표현식 (Lambda Expressions)
- 함수형 인터페이스의 인스턴스를 만드는 방법으로 쓰일 수 있다.
- 코드를 줄일 수 있다.
- 메서드 매개변수, 리턴 타입, 변수로 만들어 사용할 수도 있다.
자바에서 함수형 프로그래밍
- 함수를 First class object(일급 객체)로 사용할 수 있다.
: RunSomething runSomething = () -> System.out.println("Hello");
람다를 사용하면 다른 언어의 함수를 정의한 것처럼 보이지만 자바에서는 이런 형태를 특수한 형태의 오브젝트라고 볼 수 있다. 함수형 인터페이스를 인라인 형태로 구현한 오브젝트라 볼 수 있는데, 자바는 객체지향언어(OOP)이기 때문에 이 함수를 변수에 할당, 메서드의 파라미터로 전달, 리턴타입으로 사용할 수 있다. - 고차 함수 (Higher-Order Function)
- 함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수도 있다.
→ 자바에서는 함수가 특수한 형태의 오브젝트일 뿐이기에 당연히 가능하다.
- 함수가 함수를 매개변수로 받을 수 있고 함수를 리턴할 수도 있다.
- 순수 함수 (Pure function)
public class Foo {
public static void main(String[] args) {
RunSomething runSomething = (number) -> {
return number + 10;
};
System.out.println(runSomething.doIt(1)); // 11
System.out.println(runSomething.doIt(1)); // 11
System.out.println(runSomething.doIt(1)); // 11
System.out.println(runSomething.doIt(2)); // 12
System.out.println(runSomething.doIt(2)); // 12
System.out.println(runSomething.doIt(2)); // 12
}
}
- 수학적인 함수에서 가장 중요한 것은 입력받은 값이 동일할 때 결과가 같아야 한다는 것이다. 매개변수로 1을 넣었으면 몇 번을 호출하던 11이 나와야 하고 2를 넣었으면 몇 번이던 12가 나와야 한다. 이런 결과를 보장하지 못하거나 못할 여지가 있다면 함수형 프로그래밍이라고 할 수 없다.
- 사이드 이팩트가 없다. (함수 밖에 있는 값을 변경하지 않는다.)
- 상태가 없다. (함수 밖에 있는 값을 사용하지 않는다.)
함수 내에서 외부의 값을 참조할 수는 있다.
함수 내에서는 외부의 값을 final 이라고 보기 때문에 외부의 값을 변경할 수 없다.
정리
자바에서 함수형 프로그래밍을 할 수 있도록 제공된 함수형 인터페이스(Functional Interface), 람다 표현식(Lambda Expressions)은 굳이 함수형 프로그래밍을 안 하더라도 문법적으로 허용되기에 사용할 수 있는 기능들이다.
하지만, 함수형 프로그래밍을 하겠다고하면 이 두 가지를 사용함에 있어서 순수 함수, 불변성에 대해 고려할 필요가 있다.
📌 자바에서 제공하는 함수형 인터페이스
자바에서 기본으로 제공하는 함수형 인터페이스
자바에서 미리 정의해둔 자주 사용할만한 함수 인터페이스
- Function
- BiFunction
- Consumer
- Supplier
- Predicate
- UnaryOperator
- BinaryOperator
함수 인터페이스 분석
1. Function<T, R>
:: T타입을 받아서 R타입을 반환하는 함수 인터페이스
- R apply(T t)
: T라는 타입을 받아서 R이라는 타입을 반환하는 추상 메서드
클래스를 이용한 방법
public class Plus10 implements Function<Integer, Integer> {
@Override
public Integer apply(Integer integer) {
return integer + 10;
}
}
public class Foo {
public static void main(String[] args) {
Plus10 plus10 = new Plus10();
System.out.println(plus10.apply(1)); // 11
}
}
- Function<T, R> 인터페이스의 구현체인 Plus10 클래스를 생성 후 apply() 메서드를 구현
- Plus10의 인스턴스 생성 후 apply() 메서드를 실행해 결괏값 반환
람다 표현식(Lambda Expressions)를 이용한 방법
public class Foo {
public static void main(String[] args) {
Function<Integer, Integer> plus10 = (i) -> i + 10;
System.out.println(plus10.apply(1)); // 1;
}
}
- 람다 표현식을 이용하여 바로 구현
함수 조합용 메서드
public class Foo {
public static void main(String[] args) {
Function<Integer, Integer> plus10 = (i) -> i + 10;
Function<Integer, Integer> multiply2 = (i) -> i * 2;
Function<Integer, Integer> multiply2AndPlus10 = plus10.compose(multiply2);// 뒤에오는 함수를 적용 후 결과값을 앞에있는 함수에 입력값으로 사용
Function<Integer, Integer> plus10AndMultiply2 = plus10.andThen(multiply2);// 앞에있는 함수 적용 후 결과값을 뒤에 있는 함수에 입력값으로 사용
System.out.println(multiply2AndPlus10.apply(2)); // 12
System.out.println(integerIntegerFunction.apply(2)); // 24
}
}
- compose : 뒤에오는 함수를 적용 후 결괏값을 앞에 있는 함수에 입력값으로 사용 → ( 2*2) + 10
- andThan : 앞에있는 함수를 적용 후 결괏값을 뒤에 있는 함수에 입력값으로 사용 → (2+10) * 2
2. BiFunction<T, U, R>
:: T,U 타입을 입력받아서 R 타입을 반환하는 함수 인터페이스
public class Foo {
public static void main(String[] args) {
BiFunction<String, Integer, String> biFunction = (s, i) -> "Hello" + s + i;
System.out.println(biFunction.apply("World",2023)); // HelloWorld2023
}
- R apply(T t, U u)
- Function<T, R> 함수와 매개변수 개수만 다를 뿐 동일하다.
3. Consumer<T>
:: T 타입을 받아서 수행 후 아무 값도 반환하지 않는 함수 인터페이스
public class Foo {
public static void main(String[] args) {
Consumer<Integer> printT = (i) -> System.out.println(i);
printT.accept(1); // 1
}
- accept(T t)
- 함수조합용 메서드
- andThan
4. Supplier<T>
:: T 타입의 값을 제공하는 함수 인터페이스
public class Foo {
public static void main(String[] args) {
Supplier<Integer> get10 = () -> 10;
System.out.println(get10.get()); // 10
}
- get()
- 입력값이 없고 T타입의 값을 반환만 한다.
5. Predicate<T>
:: T 타입을 받아서 boolean을 리턴하는 함수 인터페이스
public class Foo {
public static void main(String[] args) {
Predicate<Integer> isEven = (i) -> i%2 == 0;
Predicate<Integer> isOdd = (i) -> i%2 == 1;
System.out.println(isEven.test(10)); // true
System.out.println(isEven.test(13)); // false
System.out.println(isOdd.test(13)); // true
System.out.println(isEven.and(isOdd).test(1)); // && false
System.out.println(isEven.or(isOdd).test(1)); // || true
System.out.println(isEven.negate().test(1)); // ! true
}
- boolean test(T t)
- 함수 조합용 메서드
- And
- Or
- Negate
6. UnaryOperator<T>
:: Function<T, R>의 특수한 형태로 입력값 타입과 반환값 타입이 동일한 함수 인터페이스
public class Foo {
public static void main(String[] args) {
UnaryOperator<Integer> plus20 = (i) -> i + 20;
System.out.println(plus20.apply(1)); // 21
}
}
- T apply(T t)
- 파라미터 타입과 리턴 타입이 동일할 때 사용
7. BinaryOprator<T>
:: BiFunction<T, U, R>의 특수한 형태로 입력값 타입과 반환값 타입이 동일한 함수 인터페이스
public class Foo {
public static void main(String[] args) {
BinaryOperator<String> binaryOperator = (s, i) -> "Hello" + s + i;
System.out.println(binaryOperator.apply("World","2023")); // HelloWorld2023
}
}
- T apply(T t)
- 두 개의 입력 매개변수 타입과 리턴 타입이 모두 동일할 때 사용
📌 람다 표현식
람다
람다는 기본적으로 인자와 바디로 이루어진다.
( 인자 리스트 ) → { 바디 }
- 인자 리스트
- 인자가 없을 때 : ()
- 인자가 1개 : (one) 또는 one → 괄호 생략 가능
- 인자가 여러개 : (one, two) → 괄호 생략 불가능
- 인자의 타입은 생략가능, 컴파일러가 제네릭을 보고 추론(infer)하지만 명시할 수도 있다.
ex. (Integer one, Integer two) -> { 바디 }
- 바디
- 화살표 오른쪽에 함수 본문을 정의한다.
- 여러 줄인 경우 {} 사용해서 묶는다.
- 한 줄인 경우 생략 가능하며 return도 생략 가능하다.
public class Foo {
public static void main(String[] args) {
/**** 인자리스트 예시 *****/
// 인자가 없을 때 : 인자리스트 괄호() 필수
Supplier<Integer> get10 = () -> 10;
// 인자가 하나일 경우 : 인자리스트 괄호() 생략 가능
UnaryOperator<Integer> plus10 = s -> s + 10;
// 인자가 여러개일 경우 : 인자리스트()괄호 생략 불가능
BinaryOperator<Integer> sum1 = (a, b) -> a + b;
// 인자타입은 생략 가능하지만 파라미터 타입을 명시 가능.
BinaryOperator<Integer> sum2 = (Integer a, Integer b) -> a + b;
BinaryOperator<Integer> sum3 = (a, b) -> a + b;
/** 바디 예시 **/
// 바디가 한 줄일 경우 바디 괄호{} 생략가능
Consumer<String> print1 = (s) -> System.out.println("첫 번째 줄");
Consumer<String> print2 = (s) -> {
System.out.println(s + "첫 번째 줄");
System.out.println(s + "두 번째 줄");
};
}
}
변수 캡처(Variable Capture)
private void run() {
int baseNumber = 10; // 외부지역변수, effective final variable
// final int baseNumber = 10; // {1} 이 변수를 다른곳에서 변경하지 않는 경우 자바 8부터 final 생략 가능
// 내부 클래스에서 외부지역변수 참조
class LocalClass {
void printBaseNumber() {
int baseNumber = 20; // 쉐도윙(Shadowing)으로 외부 지역변수를 덮음
System.out.println("내부 클래스 : "+baseNumber); // 20 {2}
}
}
LocalClass localClass = new LocalClass();
localClass.printBaseNumber();
// 익명 클래스에서 외부지역변수 참조
Consumer<Integer> integerConsumer = new Consumer<Integer>() {
@Override
public void accept(Integer baseNumber) { // 쉐도윙(Shadowing)으로 외부지역변수를 덮음
System.out.println("익명 클래스 : "+baseNumber); // 30 {3}
}
};
integerConsumer.accept(30);
// 람다에서 지역변수 참조
IntConsumer printInt = (i) -> {
// int baseNumber = 50; // 변수 선언 불가(쉐도잉 안됨)
System.out.println("람다 : "+ (i + baseNumber)); // 40 {4}
};
printInt.accept(30);
}
- {1} : 변수가 effective final variable일 경우 자바 8부터 final 키워드를 생략 가능하다.
- {2} : 외부 지역변수와 내부 지역변수명이 같을 경우 쉐도윙(Shadowing)으로 인해 내부 지역변수가 출력된다.
- {3} : 외부 지역변수와 내부 매개변수명이 같을 경우 쉐도윙(Shadowing)으로 인해 내부 매개변수가 출력된다.
- {4} : 람다는 내부클래스, 익명클래스와 다르게 메서드와 같은 스코프를 가지고 있기 때문에 지역변수와 동일한 이름으로 변수 생성이 불가능하다. 즉, 쉐도윙(Shadowing)되지 않는다.
baseNumber가 메서드 내 위치와 상관없이 값이 재할당되어 effective final variable가 아니게 될 경우 외부 지역변수를 바라보고있는 내부or익명 클래스나 같은 스코프 내 지역변수는 컴파일 에러 발생한다.
Effective final variabledl이란?
자바에서 final 키워드가 선언되지 않은 변수지만, 값이 재할당되지 않아 final 과 유사하게 동작하는 변수
쉐도윙(Shadowing)이란?
쉐도잉은 동일한 변수나 메서드가 스코프 내에서 중복으로 선언될 때 변수나 메서드의 이름이 겹치는 경우에 발생하며, 스코프 내에서 가까운 범위에 있는 변수 또는 메서드가 우선적으로 접근되어 사용된다.
정리
- 람다는 외부 block에 있는 변수에 접근 가능
- 외부에 있는 변수가 지역 변수 일 경우 final 변수 또는 effective final variable 변수 인 경우만 접근 가능
📌 메서드 레퍼런스
람다 표현식을 구현할 때, 기존에 있던 메서드나 생성자를 참조하는 것
메서드 참조하는 방법
static 메서드 참조 | 타입 :: static 메서드 |
특정 객체의 인스턴스 메서드 참조 | 객체 레퍼런스 :: 인스턴스 메서드 |
임의 객체의 인스턴스 메서드 참조 | 타입 :: 인스턴스 메서드 |
생성자 참조 | 타입 :: new |
예제를 위한 참조클래스
public class Greeting {
private String name;
public Greeting() {
}
public Greeting(String name) {
this.name = name;
}
public String hello(String name) {
return "hello " + name;
}
public static String hi(String name) {
return "hi " + name;
}
}
기존의 방식대로 기능 구현
public static void main(String[] args) {
UnaryOperator<String> hi = (s) -> "hi " + s;
System.out.println(hi.apply("java")); // hi java
}
메서드 레퍼런스를 사용한 기능 구현 (static 메서드 참조)
UnaryOperator<String> hi = Greeting::hi;
System.out.println(hi.apply("java")); // hi java
- Greeting 클래스에 구현되어 있는 static 메서드 hi를 메서드 레퍼런스를 사용하여 다음과 같이 호출할 수 있다.
메서드 레퍼런스를 사용한 기능 구현 (특정 객체의 인스턴스 메서드 참조)
Greeting greeting = new Greeting();
UnaryOperator<String> hello = greeting::hello;
System.out.println(hi.apply("java")); // hello java
- Greeting클래스 인스턴스 생성 후 인스턴스의 메서드 hello를 메서드 레퍼런스를 사용하여 다음과 같이 호출할 수 있다.
메서드 레퍼런스를 사용한 기능 구현 (생성자 메서드 참조)
// 입력값이 없는 생성자 메서드 참조
Supplier<Greeting> newGreeting = Greeting::new;
Greeting newMethodGreeting = newGreeting.get();
// 입력값을 있는 생성자 메서드 참조
Function<String, Greeting> newGreetingWithName = Greeting::new;
Greeting newMethodParamGreeting = newGreetingWithName.apply("java");
- 입력값은 존재하지 않지만 반환값은 존재하는 경우 Supplier<T>를 통해 구현할 수 있다.
기본 생성자 또한 입력값은 없고 반환값만 존재하지 않기 때문에 Supplier<T>로 구현할 수 있다. - 입력값이 존재하는 생성자는 Function<T,R>을 통해 구현 할 수 있다.
생성자 입력값으로 String 인자 값을 전달받아 생성된 Greeting 타입 인스턴스를 반환한다.
참고 : Supplier<T>, Function<T,R>를 통해 구현한 생성자 메서드 참조 함수는 생성자 함수를 만든 것 뿐이지 실제 인스턴스가 생성된 것은 아니기 때문에 함수 구현 후 get(), apply()를 호출해서 인스턴스를 만들어줘야한다.
메서드 레퍼런스를 사용한 기능 구현 (임의 객체의 인스턴스 메서드 참조 → 불특정 타입의 불특정 인스턴스의 메서드를 참조)
String[] names = {"member3", "member2", "member1"};
// 기존의 익명클래스를 사용한 방법
Arrays.sort(names, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return 0;
}
});
// 람다 사용
Arrays.sort(names,((o1, o2) -> 0));
// 메서드 레퍼런스 사용
Arrays.sort(names, String::compareToIgnoreCase);
System.out.println(Arrays.toString(names));
- 기존에는 익명 클래스를 이용해 정렬을 구현해야했다.
- 자바 8이후, Comparator클래스는 @FunctionalInterface가 되었고 함수형 인터페이스이기 때문에 람다 표현식으로 사용할 수 있게 되었다.
- 람다 표현식으로 사용할 수 있다는 말은 메서드 레퍼런스를 사용할 수 있다는 의미이기 때문에 String 클래스의 compareToIgnoreCase 메서드를 사용하며 names의 파라미터를 넘겨서 정렬할 수 있다.