자바8부터 나타난 함수형 인터페이스에 대해 알아보는 시간을 가져보자.
함수형 인터페이스 (Functional Interface)
- 추상 메서드를 딱 하나만 가지고 있는 인터페이스를 말한다. (SAM 인터페이스라고도 한다) (SAM = Single Abstract Method)
- @FunctionalInterface 애노테이션을 가지고 있는 인터페이스를 말한다.
다음 코드를 보자.
@FunctionalInterface
public interface Hello {
void hello();
}
- 위 코드처럼 추상 메서드가 딱 하나만 있을때 이 인터페이스를 함수형 인터페이스라고 한다.
- 그리고 함수형 인터페이스를 정의할 일이 있다면 @FunctionalInterface 애노테이션을 사용해서 이 인터페이스가 함수형 인터페이스임을 명확히 하는게 좋다. 왜냐하면 추상 메서드가 한개가 아닌 경우 아래 사진처럼 컴파일 에러를 만들어주기 때문이다.
이런 경우도 함수형 인터페이스이다.
@FunctionalInterface
public interface Hello {
void hello();
int h = 100;
static void howAreYou() {
System.out.println("how are you");
}
default void hi() {
System.out.println("Hello");
}
}
- 자바8부터 인터페이스에 이렇게 메서드를 직접 정의할수도 있게 됐는데 그런것이 몇 개가 있던 결국 가장 중요한건 추상 메서드가 딱 한개이냐 아니냐로 함수형 인터페이스는 정의된다.
- 여기서도 마찬가지로 abstract void hello(); 하나뿐이므로 이 인터페이스는 함수형 인터페이스이다.
- 참고로, 인터페이스에서 abstract는 생략 가능하다.
그럼 이렇게 정의한 함수형 인터페이스를 어떻게 사용하면 되느냐? 다음 코드를 보자.
public class FIMain {
public static void main(String[] args) {
Hello h = new Hello() {
@Override
public void hello() {
System.out.println("hello");
}
};
h.hello();
}
}
- 자바8 이전에는 이런식으로 사용했다. 이거를 익명 내부 클래스라고 했었다. 굳이 구현체를 클래스로 따로 만들지 않아도 이렇게 작성하면 되니까 익명 내부 클래스라는 이름이 붙은 것 같다.
- 지금도 당연히 이렇게 사용할 수 있다.
그런데 이 코드를 획기적으로 줄일 수가 있다. 다음 코드를 보자.
public class FIMain {
public static void main(String[] args) {
Hello h = () -> System.out.println("hello");
h.hello();
}
}
- 단 한줄로 변경된 모습이 보이는가? 이게 함수형 인터페이스를 람다로 표현한다고 한다.
- 물론, 실행 부분이 저렇게 딱 한줄일때 이렇게 사용할 수 있고 만약 한 줄 이상이라면 다음과 같이 사용하면 된다.
public class FIMain {
public static void main(String[] args) {
Hello h = () -> {
System.out.println("hello");
System.out.println("Hi");
};
h.hello();
}
}
- 그럼에도 불구하고 익명 내부 클래스보단 획기적으로 코드양이 줄어 보인다.
- 이런 표현식을 람다 표현식이라고 한다.
자바에서 제공하는 함수형 인터페이스
개발자가 직접 구현하지 않고 자바에서 자체적으로 제공해주는 함수형 인터페이스가 있다. 바로 다음 패키지에 있는 함수형 인터페이스들이다.
java.util.function
위 패키지에서 여러가지 함수형 인터페이스가 있는데 대표적인 것들을 살펴보자.
- Function<T, R>
- BiFunction<T, U, R>
- Consumer<T>
- Supplier<T>
- Predicate<T>
- UnaryOperator<T>
- BinaryOperator<T>
Function<T, R>
이 함수형 인터페이스는 인풋 T를 받고, 아웃풋 R을 반환한다. 코드로 바로 알아보자.
import java.util.function.Function;
public class FIMain {
public static void main(String[] args) {
Function<Integer, Integer> plus = (number) -> number + 1;
System.out.println(plus.apply(2));
}
}
- Integer를 받아서 Integer를 반환하는 plus 라는 이름의 함수형 인터페이스 Function을 구현했다.
- 이 함수형 인터페이스는 apply라는 추상 메서드를 구현해야 하고 그것을 우리는 받은 인자에 + 1을 한 값을 리턴하는 것으로 구현했다.
- 이 녀석의 apply(2)를 실행하면 결과는 3이 나오게 된다.
실행 결과
이런식으로도 작성할 수 있겠다.
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Function<Integer, Integer> plusWithOne = (x) -> x + 1;
Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;
System.out.println(multiplyWithTwo.apply(2));
}
}
- 인자값을 받아 2를 곱하는 함수형 인터페이스 Function을 구현했다.
- 실행하면 당연히 결과는 4가 나온다.
실행 결과
그런데, 이 두개의 Function을 조합할수도 있다. Function에서 제공하는 compose()라는 메서드가 있다.
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Function<Integer, Integer> plusWithOne = (x) -> x + 1;
Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;
Function<Integer, Integer> compose = plusWithOne.compose(multiplyWithTwo);
System.out.println(compose.apply(2));
}
}
- 이렇게 compose로 Function과 Function을 조합하는 게 가능하다.
- compose는 인자로 받은 Function을 먼저 apply 수행하고, 그 결과값을 다시 apply해서 결과를 반환한다.
- 그러니까, compose.apply(2)를 하면, 먼저 multiplyWithTwo를 실행해서 4를 만들고, 그 결과값을 plusWithOne.apply()에 인자로 넣어 실행하게 된다. 그래서 결과는 5가 나온다.
실행 결과
이번에는 이런 것도 있다.
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Function<Integer, Integer> plusWithOne = (x) -> x + 1;
Function<Integer, Integer> multiplyWithTwo = (x) -> x * 2;
Function<Integer, Integer> andThen = plusWithOne.andThen(multiplyWithTwo);
System.out.println(andThen.apply(5));
}
}
- andThen(). 이것도 역시 두 Function을 조합하는데, 이건 받은 Function이 뒤에 실행된다.
- 그러니까, 먼저 plusWithOne.apply()가 실행되고, 그 실행된 결과값을 multiplyWithTwo.apply()인자로 받아 실행된 결과값을 반환한다. 그래서 결과는 12가 나온다.
실행 결과
BiFunction<T, U, R>
이 BiFunction은 Function이랑 똑같은데, 인자값을 2개(T, U) 받는다. 그래서 R을 반환한다.
import java.util.function.BiFunction;
public class Main {
public static void main(String[] args) {
BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
System.out.println(add.apply(1, 2));
}
}
- 이런식으로 작성할 수 있고, 결과는 3이 나오게 된다.
실행 결과
Consumer<T>
이건 반환값이 없다. 추상 메서드의 반환 타입이 void다. 이름 그대로 소비자의 역할을 한다고 보면 된다.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
Consumer<Integer> printer = (x) -> System.out.println(x);
printer.accept(1);
}
}
- 얘는 추상 메서드 명이 accept()이다. 그리고 위와 같이 반환값이 없다. 간단하게 받은 파라미터를 출력하도록 작성했다.
실행 결과
Supplier<T>
이 녀석은 반대다. 받는게 없고 반환만 한다. 이름 그대로 공급자의 역할을 한다고 보면 된다.
import java.util.function.Supplier;
public class Main {
public static void main(String[] args) {
Supplier<Integer> get10 = () -> 10;
System.out.println(get10.get());
}
}
- 얘는 추상 메서드 명이 get()이다. 공급자라는 이름에 아주 걸맞는 메서드명이다.
- 위 코드처럼 받는것은 없고 반환만 한다.
Predicate<T>
이 녀석은, 어떤 인자값을 받아 boolean 타입을 반환한다.
import java.util.function.Predicate;
public class Main {
public static void main(String[] args) {
Predicate<Integer> isOne = (x) -> x == 1;
System.out.println(isOne.test(1));
System.out.println(isOne.test(2));
}
}
- 위 코드와 같이 인자값을 받아서 그 인자값이 1인지 확인하는 뭐 이런 코드를 작성할 수 있겠다.
- 추상 메서드의 이름은 test()이다.
실행 결과
그리고 이 녀석은 반환값이 boolean이기 때문에 이런 조합 메서드를 제공한다.
- and()
- or()
- negate()
import java.util.function.Predicate;
public class Main {
public static void main(String[] args) {
Predicate<Integer> isOne = (x) -> x == 1;
Predicate<Integer> isOdd = (x) -> x % 2 != 0;
Predicate<Integer> andPredicate = isOne.and(isOdd);
System.out.println(andPredicate.test(1));
}
}
- 두 Predicate을 조합하는데 AND 조건으로 조합해서 결과를 반환한다.
- 위 코드를 보면 하나는 받은 인자가 1인지, 하나는 받은 인자가 홀수인지를 체크하는 Predicate이다.
- 이 두개를 AND로 조합해서 둘 다 true를 반환하면 true를 반환하고 둘 중 하나라도 false라면 false를 반환한다.
실행 결과
그럼 or(), negate()은 예측이 가능하다.
import java.util.function.Predicate;
public class Main {
public static void main(String[] args) {
Predicate<Integer> isOne = (x) -> x == 1;
Predicate<Integer> isOdd = (x) -> x % 2 != 0;
Predicate<Integer> orPredicate = isOne.or(isOdd);;
System.out.println(orPredicate.test(3));
}
}
- 하나는 1인지, 하나는 홀수인지를 체크하는 Predicate인데 이 두 개를 or()로 연결했다.
- 둘 중 하나라도 true라면 true를 반환하고, 둘 중 하나라도 false라면 false를 반환할 것이다.
실행 결과
import java.util.function.Predicate;
public class Main {
public static void main(String[] args) {
Predicate<Integer> isOne = (x) -> x == 1;
Predicate<Integer> isOdd = (x) -> x % 2 != 0;
Predicate<Integer> negate = isOne.negate();
System.out.println(negate.test(1));
}
}
- negate()은 역이다. true라면 false를, false라면 true를 반환한다.
실행 결과
UnaryOperator<T>
이 녀석은 편리함을 제공해주는 녀석이다. 다음 코드를 보자.
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Function<Integer, Integer> plusWithOne = (x) -> x + 1;
}
}
- 위 코드처럼 입력값과 반환값의 타입이 동일한 경우에 다음과 같이 사용할 수가 있다.
import java.util.function.UnaryOperator;
public class Main {
public static void main(String[] args) {
// Function<Integer, Integer> plusWithOne = (x) -> x + 1;
UnaryOperator<Integer> plusWithOne = (x) -> x + 1;
}
}
- 입력값과 반환값이 같은 경우, UnaryOperator를 사용해서 좀 더 간결하게 작성할 수가 있다.
- 그리고 이 UnaryOperator는 Function을 상속받는다. 그래서 Function이 제공하는 조합 메서드(compose(), andThen(), ...)을 사용할 수 있다.
BinaryOperator<T>
이 녀석은 BiFunction<T, U, R>의 간편 메서드라고 보면 된다. 이 BiFunction은 두 개의 인자를 받아 R타입을 반환하는 녀석이다. 근데 이 세 개의 타입(T, U, R)이 모두 같은 경우 이 BinaryOperator<T>를 사용할 수가 있다.
import java.util.function.BinaryOperator;
public class Main {
public static void main(String[] args) {
// BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
BinaryOperator<Integer> add = (x, y) -> x + y;
}
}
이렇게 대표적인 것들을 알아봤는데 직접 저 패키지 안으로 들어가보면, 이 말고도 굉장히 뭐가 많다. 근데 지금까지 배운것들의 응용이라고 생각하면 된다. 이름만 봐도 "아 이건 이거겠구나!"를 추측할 수 있을 것이다. 예를 들어, 이런 거다.
직접 이 패키지에 뭐가 있는지 쭉 보면, 맨 위에 BiConsumer<T, U>가 있다. 그럼 우린 Consumer<T>를 배웠기 때문에 이게 뭔지 추측이 가능하다. "아, 두 개의 인자를 받아서 아무것도 반환은 안하고 T와 U를 가지고 뭔가를 하겠구나?" 맞다.
변수 캡처
이 부분은 꽤나 중요하다. 자세히 들여다보자. 함수형 인터페이스와 람다 표현식을 사용할때 주의할 점이 있다. 바로 이 변수 캡처에 대한 내용인데 다음 코드를 보자.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
final int number = 10;
// 로컬 클래스
class LocalClass {
void local() {
System.out.println(number);
}
}
// 익명 내부 클래스
Consumer<Integer> consumer = new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(number);
}
};
// 람다
Consumer<Integer> lambda = (x) -> System.out.println(x + number);
lambda.accept(number);
}
}
- 로컬 클래스든, 익명 내부 클래스든, 람다 표현식이든 변수를 참조를 할수가 있다. 근데 자바8 이전에는 그 변수는 반드시 final 키워드가 붙어야 했다. 즉, 선언 이후 절대로 변경되지 않을 변수만 참조가 가능하다.
- 그런데, 자바8 이후부터는 이런 경우에 final을 붙이지 않아도 참조할 수 있다. 다음 코드를 보자.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
int number = 10;
// 로컬 클래스
class LocalClass {
void local() {
System.out.println(number);
}
}
// 익명 내부 클래스
Consumer<Integer> consumer = new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(number);
}
};
// 람다
Consumer<Integer> lambda = (x) -> System.out.println(x + number);
lambda.accept(number);
}
}
- 이 역시 문법적으로 아무런 문제도 되지 않는 올바른 코드이다. 이게 이제 가능한데 이거를 "사실상 final" 이라고 표현한다.
- "사실상 final" 이란 말은, final 이라는 키워드는 붙지 않았지만, 이 값이 final처럼 취급되는 경우를 말한다.
- 여기서 만약, 이후에 값을 변경하려고 하면 어떻게 될까? 아래와 같이 컴파일 오류가 발생한다.
즉, 선언한 후 값이 변경되지 않는 변수에 대해서는 로컬 클래스든, 익명 내부 클래스든, 람다든 변수를 참조할 수 있는데 이것을 변수 캡처라고 한다. 그런데 여기서 더 중요한 사실이 있다. [로컬 클래스, 익명 내부 클래스]와 람다는 차이가 있다.
다음 코드를 보자.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
final int number = 10;
// 로컬 클래스
class LocalClass {
final int number = 55;
void local() {
int number = 30;
System.out.println(number);
}
}
new LocalClass().local();
// 익명 내부 클래스
Consumer<Integer> consumer = new Consumer<Integer>() {
final int number = 40;
@Override
public void accept(Integer integer) {
System.out.println(number + integer);
}
};
consumer.accept(4);
// 람다
Consumer<Integer> lambda = (x) -> System.out.println(x + number);
lambda.accept(5);
}
}
- 벌써 어지럽다. 근데, 결론적으로 로컬 클래스와 익명 내부 클래스는 로컬 변수를 가질 수가 있다.
- 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 로컬클래스에서 local() 메서드 안에 또 다른 로컬 변수 number를 선언했다.
- 위 코드를 보면 바깥에 있는 main 메서드에 number라는 변수를 선언했는데, 익명 내부 클래스 안에서 또 다른 로컬 필드인 number를 선언했다.
- 이 경우에는 Scope이 어떻게 결정될까? 실행 결과를 보자.
- 이러한 결과가 나왔다. 어찌보면 당연한 것이다. 물론, 익명 내부 클래스도 accept() 메서드 안에 로컬 변수로 선언해도 상관없다. 로컬 클래스와 같이 더 가까이에 있는게 적용된다.
그런데, 람다는 아니다! 람다는 Scope이 람다만의 Scope은 없다. main 메서드와 Scope을 같이한다. 즉, 람다를 감싸고 있는 녀석과 같다는 말이다. 그러니까 아래 사진을 보자. 컴파일 오류가 난다. 당연하다. 왜냐? 같은 Scope에 동일한 이름의 변수를 만들 수 없는건 너무 당연하니까!
메서드 레퍼런스
메서드 레퍼런스는 뭘까? 다시 한번 아래 코드를 보자.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
Consumer<Integer> lambda = (x) -> System.out.println(x);
lambda.accept(5);
}
}
- Consumer를 람다 표현식으로 만들었다. 그리고 바디에서 하는 일은 입력값을 그대로 시스템 콘솔에 출력한다.
- 그런데 이렇게 람다의 바디에서 하는 일이 기존 메서드 또는 생성자를 호출하는 거라면, 메서드 레퍼런스를 사용해서 매우 간결하게 표현할수가 있는데 다음 코드를 보자.
import java.util.function.Consumer;
public class Main {
public static void main(String[] args) {
Consumer<Integer> lambda = System.out::println;
lambda.accept(5);
}
}
- 이게 바로 메서드 레퍼런스이다. 훨씬 더 깔끔해졌다. 그냥 System.out의 println()을 호출하는 것 뿐이고 그런 경우에는 이렇게 메서드 레퍼런스로 매우 간결하게 출력할 수 있다.
- 당연히 출력하는 값은 Consumer는 입력값을 받기 때문에 입력값을 그대로 출력한다.
실행 결과
굳이 따지자면, 이렇게 4가지가 가능하다.
- 타입::스태틱 메서드
- 객체 레퍼런스::인스턴스 메서드
- 타입::인스턴스 메서드
- 타입::new
타입::스태틱 메서드
public class MethodRef {
public static String staticMethod(String s) {
return "Hello " + s;
}
}
자, 위와 같은 클래스가 있다고 해보자.
import java.util.function.UnaryOperator;
public class Main {
public static void main(String[] args) {
UnaryOperator<String> lambda = MethodRef::staticMethod;
System.out.println(lambda.apply("World"));
}
}
- 똑같이 String을 받아서 String을 반환한다면, 타입::스태틱 메서드 형태로 표현할 수 있다.
실행 결과
객체 레퍼런스::인스턴스 메서드
public class MethodRef {
public String instanceMethod(String s) {
return "Hello " + s;
}
public static String staticMethod(String s) {
return "Hello " + s;
}
}
import java.util.function.UnaryOperator;
public class Main {
public static void main(String[] args) {
MethodRef methodRef = new MethodRef();
UnaryOperator<String> lambda = methodRef::instanceMethod;
System.out.println(lambda.apply("World"));
}
}
- 위 코드처럼 인스턴스가 있고, 인스턴스의 메서드를 가지고 람다 표현식을 깔끔하게 쓸 수 있다.
실행 결과
타입::new
public class MethodRef {
private String s;
public MethodRef() {
}
public MethodRef(String s) {
this.s = s;
}
public String getS() {
return s;
}
public String instanceMethod(String s) {
return "Hello " + s;
}
public static String staticMethod(String s) {
return "Hello " + s;
}
}
자, 이번엔 필드하나를 추가하고 생성자 두개를 추가했다.
import java.util.function.Supplier;
public class Main {
public static void main(String[] args) {
Supplier<MethodRef> methodRef = MethodRef::new;
MethodRef methodRefBySupplier = methodRef.get();
}
}
- 이런게 가능하다. 생성자를 호출할 수도 있다.
- 여기서 호출한 생성자는 뭘까 그럼? Supplier는 아무런 인자도 받지 않는다. 그리고 반환하는 것이 MethodRef다. 이런 경우엔? 저기 위에서 선언한 기본생성자를 호출하는 것이다.
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Function<String, MethodRef> methodRef = MethodRef::new;
MethodRef hello = methodRef.apply("hello");
System.out.println(hello.getS());
}
}
- 이번에도 역시 생성자를 호출하는데, 지금 보면 Function으로 되어 있다. 즉, 받는 값이 있고 반환하는 값이 있는건데, 받는 값이 String이고 반환값이 MethodRef다. 이 경우에는 당연히 기본 생성자가 아니라 String을 받는 생성자를 호출하는 것이다!
- 실제로 apply("hello")를 호출했을때, 필드 `s`에 저 값이 들어가는지 확인해보면 다음과 같다.
실행 결과
타입::인스턴스 메서드
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
String[] fruit = {"apple", "tomato", "orange"};
Arrays.sort(fruit, String::compareToIgnoreCase);
}
}
- 이런게 타입::인스턴스 메서드이다.
- 보면, Arrays.sort()를 사용해서 배열을 받고 정렬을 하고 있다.
- 정렬할때 두번째 인자로, Comparator를 넣어줘야 하는데, 보통은 이제 아래와 같이 사용을 할 것이다.
import java.util.Arrays;
import java.util.Comparator;
public class Main {
public static void main(String[] args) {
String[] fruit = {"apple", "tomato", "orange"};
Comparator<String> comparator = (o1, o2) -> o1.compareTo(o2);
Arrays.sort(fruit, comparator);
}
}
- 그런데 이제, 이렇게 사용이 가능하다는 것이다.
Arrays.sort(fruit, String::compareToIgnoreCase);
- 이건 이제, fruit 안에 있는 문자열 두개를 가져와서 서로 비교를 하고 있는건데, String 이라는 타입의 "apple", "tomato", "orange"라는 인스턴스의 메서드인 compareToIgnore()을 사용하고 있는 타입::인스턴스 메서드이다.
인터페이스 기본 메서드와 스태틱 메서드
또한, 자바8부터 제공되는 인터페이스의 기본 메서드와 스태틱 메서드도 알아보자. 자 다음 코드를 보자.
public interface Hello {
void printHello();
}
- Hello 라는 인터페이스 하나가 있다.
public class HelloImpl implements Hello {
@Override
public void printHello() {
}
}
- Hello 를 구현한 HelloImpl 클래스가 있다.
그런데 만약 이때, Hello를 구현하는 모든 구현체는 다 동일하게 어떤 메서드를 가지고 싶게 하려면 어떻게 하면 될까? 모두 같은 기능을 하는 메서드인데 인터페이스에 또 추상 메서드를 하나 선언해버리면? 다음과 같은 컴파일 오류가 날 것이다.
public interface Hello {
void printHello();
void printHi();
}
이런 경우에 자바8부터는 인터페이스에 기본 메서드를 만들 수 있도록 했다. 바로 이렇게 말이다.
public interface Hello {
void printHello();
default void printHi() {
System.out.println("Hi");
}
}
- 이렇게 default 라는 키워드를 사용하여 메서드를 직접 인터페이스에서 구현할 수 있게 했고, 이때 이 인터페이스를 구현하는 구현체는 모두 동일하게 이 메서드를 사용할 수 있다.
public class HelloImpl implements Hello {
@Override
public void printHello() {
printHi();
}
}
- 조금 모양새가 웃기긴 하지만, printHello() 메서드 안에 기본 메서드인 printHi()를 호출하고 있다.
기본 메서드의 탄생 취지
→ 해당 인터페이스를 구현한 클래스를 깨뜨리지 않고 모든 구현한 클래스에 동일한 새 기능을 추가할 수 있다.
그런데, 이 기본 메서드는 구현체가 모르게 추가된 기능이기 때문에 그만큼 리스크가 있다. 어떤 리스크가 있을까?
기본 메서드의 리스크
public interface Hello {
void printHello();
default void printHi(String yourName) {
System.out.println(yourName.toUpperCase() + ", Hello!");
}
}
- 예를 들어, 기본 메서드를 하나 만들었는데 파라미터로 이름을 받는다. 그리고 그 이름을 toUpperCase()를 호출하는데 만약 넘겨진 이름이 null 이라면? 런타임 오류가 발생할 수 있다.
- 그래서, 최대한 이런 예측 못한 에러를 방지할 수 있도록 다음과 같이 문서화 하는 것을 꼭 염두하자.
public interface Hello {
void printHello();
/**
* @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
* @param yourName 이름
*/
default void printHi(String yourName) {
System.out.println(yourName.toUpperCase() + ", Hello!");
}
}
기본 메서드의 여러가지 규칙들
기본 메서드를 만들 때, Object가 제공하는 기능(toString(), equals(), ...)은 기본 메서드로 제공할 수 없다. 기본 메서드로 선언하는 순간 다음과 같은 컴파일 오류가 발생한다.
인터페이스를 상속받는 인터페이스에서 다시 추상 메서드로 변경도 가능하다.
public interface Hello {
void printHello();
/**
* @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
* @param yourName 이름
*/
default void printHi(String yourName) {
System.out.println(yourName.toUpperCase() + ", Hello!");
}
}
public interface HelloExtends extends Hello {
void printHi(String yourName);
}
- Hello를 상속받는 HelloExtends가 printHi를 다시 추상 메서드로 변경했다.
- 이제 이 HelloExtends를 구현하는 구현체는 반드시 printHi(String yourName)을 구현해야 한다.
두 개의 인터페이스가 같은 시그니처를 갖는 기본 메서드를 제공할 경우에는 그 두 인터페이스를 둘 다 구현하는 구현체는 무조건 동일한 시그니처를 갖는 기본 메서드를 재정의해야 한다.
예를 들어 아래 코드를 보자.
public interface Hello {
void printHello();
/**
* @implSpec 파라미터로 이름을 받아서 해당 이름을 대문자로 변경한 후, ", Hello!"를 붙여 출력한다. 따라서 이름은 반드시 필수로 받아야 한다.
* @param yourName 이름
*/
default void printHi(String yourName) {
System.out.println(yourName.toUpperCase() + ", Hello!");
}
}
public interface Hi {
default void printHi(String yourName) {
System.out.println(yourName.toUpperCase() + ", Hi!");
}
}
- Hello, Hi를 둘 다 구현하는 HelloHiImpl 클래스는 오류가 발생한다. 왜냐하면, 완전히 동일한 시그니처를 가지는 기본 메서드가 둘 다 있기 때문에 어떤걸 사용해야 할지 컴파일러는 애매하기 때문이다. 그래서 이런 경우에는 반드시 해당 기본 메서드를 재정의 해야 한다. 아래와 같이.
public class HelloHiImpl implements Hello, Hi {
@Override
public void printHello() {
}
@Override
public void printHi(String yourName) {
System.out.println("HH");
}
}
이제 스태틱 메서드를 알아볼건데 이건 뭐 없다. 그냥 인터페이스에 스태틱 메서드를 만들 수 있다가 끝이다.
public interface Hello {
static String returnString(String a) {
return a + "string";
}
}
- 이렇게 인터페이스에도 스태틱 메서드를 만들 수가 있다.
public class Main {
public static void main(String[] args) {
String gg = Hello.returnString("gg");
System.out.println(gg);
}
}
- 사용하는 것도 스태틱 메서드 사용하는것 그대로 동일하다.
인터페이스의 기본 메서드가 가져온 새로운 혁신
인터페이스의 기본 메서드가 생기고 나서부터 엄청난 혁신이 생기게 됐다. 자바8 이전에는 인터페이스가 이렇게 있었다면,
public interface Something {
void a();
void b();
void c();
}
이 인터페이스를 구현하는 구현체 하나를 두기도 했다.
public abstract class SomethingAbstract implements Something {
@Override
public void a() {
}
@Override
public void b() {
}
@Override
public void c() {
}
}
- 왜 이랬을까? 저 인터페이스를 구현하는 구현체에게 편리함을 제공하기 위해서다.
- 자바8 이전에는 저 인터페이스를 구현하는 구현체는 좋든 싫든 a(), b(), c()를 모두 구현해야만 했다.
- 그게 너무 싫으니, 이렇게 아무것도 없는 껍데기뿐인 Abstract 클래스를 하나 만들고 인터페이스를 구현하게 하고 이 클래스를 상속받는 클래스를 만들어서 본인이 원하는 메서드만 구현하게 했던 것이다.
public class SomethingA extends SomethingAbstract {
@Override
public void a() {
System.out.println("SomethingA");
}
}
- 이렇게 말이다.
- 그런데 이제는 인터페이스에서 기본 메서드를 구현할 수 있으니 이런 불편함을 없애는것과 동시에 혁신적 혁명이 일어난다.
- 왜 혁명일까? 인터페이스는 상속이 아니라 구현이기 때문에 아무리 많이 구현해도 상관이 없고 상속의 강제화에서 벗어날 수 있기 때문이다.
이러한 이유로 인터페이스의 기본 메서드는 라이브러리나 프레임워크를 만들때 굉장히 자주 빈번하게 사용되는 것을 볼 수 있다. 스프링도 그렇다.
자바 API 기본 메서드
자바8부터 굉장히 많은 것들이 추가가 됐다. 그 중 대표적인 게 Stream API인데, 이건 이제는 너무 중요하기도 하고 자주 사용되기 때문에 아예 포스팅 하나를 새로 만들어서 이것만 다뤄보기로 할것이고 여기서는 맛보기 정도를 해보자.
Iterable의 기본 메서드
- forEach()
- spliterator()
이 두개를 한번 맛보자. forEach()는 순회하는 기능인데, 이거 보면 재밌다.
- forEach()가 인자로 무엇을 받고 있나? 바로 Consumer다. 위에서 배운 Consumer.
- Consumer를 받는다는 것은 람다로 표현할 수 있다는 뜻이고, 인자를 받지만 반환은 하지 않는 그런 함수형 인터페이스이다.
- 그래서 다음과 같이 사용할 수 있다.
friends.forEach(friend -> System.out.println(friend));
- 아 Consumer를 배우니까 이게 어떤것인지 너무나 명확하게 이해가 된다.
- 순회하는 각 요소를 Consumer의 인자로 주고 그 안에서 무언가를 하지만 반환은 하지 않는 것이다.
- 그리고, 저렇게 작성하면? 그렇다. 메서드 레퍼런스를 사용해서 더 간단하게 축약할 수 있다.
friends.forEach(System.out::println);
spliterator()는 자주 사용되는 것은 아니지만, 알아둬서 나쁠건 없다. 이 녀석 역시 순회를 하는데 이 녀석도 재밌다.
import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
Spliterator<String> spliterator = friends.spliterator();
while (spliterator.tryAdvance(System.out::println));
}
}
- 역시 마찬가지로 순회를 하는데 얘는 꼭 next()를 호출하는 것 같은 생김새다.
- 이 친구는 next() 대신 tryAdvance()를 사용하는데 그 안에 어떤 작업을 할지도 작성할 수 있다.
- 그리고 이름에 split이 있는거 보니, 쪼갤 수도 있는 것 같다. 맞다.
import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
Spliterator<String> spliterator = friends.spliterator();
Spliterator<String> stringSpliterator1 = spliterator.trySplit();
while (stringSpliterator1.tryAdvance(System.out::println));
System.out.println("=================================");
while (spliterator.tryAdvance(System.out::println));
}
}
- 이렇게 반으로 쪼갤수도 있다. 그리고 각각을 순회시켜서 출력해보면 결과는 다음과 같다.
그 외 여러가지 API가 있는데, 이후에 작성할 포스팅에서 Stream API를 배워보면서 자세하게 배워보자!
'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글
[Java 8] Optional (0) | 2024.11.27 |
---|---|
[Java 8] Stream API (0) | 2024.11.27 |
애노테이션 (6) | 2024.10.21 |
리플렉션 (6) | 2024.10.21 |
ServerSocket으로 HTTP 서버 만들기 (2) | 2024.10.18 |