메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

자바 8 함수형 인터페이스

한빛미디어

|

2014-09-04

|

by HANBIT

27,286

제공 : 한빛 네트워크
저자 : Madhusudhan Konda
역자 : 윤석진
원문 : Java 8 functional interfaces

각양각색의 특별한 util.function 패키지에 있는 comsumer, predicate, supplier와 같은 인터페이스들을 알아보자.

Madhusudhan Konda 첫번째 시리즈에서 우리는 람다 유형의 함수형 인터페이스들에 대해 배웠다. 함수형 인터페이스란 - 하나의 추상 메소드가 선언된 인터페이스다. 자바의 API는 많은 단일 메소드 인터페이스들을 가지고 있다. Runnable, Callable, Comparator, ActionListener 등 이 인터페이스들은 상속을 받거나 인스턴스화하거나 익명클래스 문법을 이용해서 사용할 수 있다. 예를 들어 ITrade 함수형 인터페이스를 보자. 단 하나의 추상 메소드를 가지고 있고 Trade 객체를 받아서 boolean 값을 리턴한다. trade의 상태를 체크하거나 주문의 유효성 또는 어떤 다른 조건을 테스트할 수 있다.
@FunctinonaInterface
public interface ITrade{
  public boolean check( Trade t );
}
새로운 Trade들에 대한 상태를 체크하기 위해서 람다 표현식으로 작성해보자. 작성한 코드는 아래와 같다.
ITrade newTradeChecker = (Trade t) -> t.getStatus().equals("NEW");

// Or we could omit the input type setting:
ITrade new TradeChecker = (t) -> t.getStatus().equals("NEW");
->를 token으로 해서 좌측에는 입력받을 객체를 지정하고, 우측에는 실제로 작동해야 할 메소드 몸체를 기술한다. 이 문장은 Trade객체의 상태를 체크하고 boolean 값을 리턴한다. 위의 예시는 이해를 돕기 위한 간단한 표현이지만 람다 사용시에 얻을 수 있는 진짜 파워는 실세계를 표현할 수 있는 다수의 행위적인 함수들을 만들 때다. 예를 들면, 부가적으로 우리가 이미 봤듯이 여기에 큰 trade(1 million 이상) 또는 새롭게 생성된 큰 google trade를 찾기 위한 람다 표현식들이 있다.
// Lambda for big trade
ITrade bigTradeLambda = (Trade t) -> t.getQuantity() > 10000000;

// Lambda that checks if the trade is a new large google trade
ITrade issuerBigNewTradeLambda = (t) -> {

  return t.getIssuer().equals("GOOG") &&
         t.getQuantity() > 1000000 &&
         t.getStatus().equals("NEW");
};
이 함수들은 메소드로 전달될 수 있다. ( 대부분 서버 사이드) ITrade를 하나의 파라미터로서 갖는다. Trade 타입의 Collection이 있을 때 특정 조건에 의해서 필터링되도록 해보자. 람다 표현식을 사용하면 이 문제를 쉽게 해결할 수 있다. 람다 표현식으로 작성했던 ITrade와 Trade 타입을 저장할 수 있는 List 타입의 collection 2개의 파라미터를 입력받는 코드를 작성해보자.
private List filterTrades(ITrade tradeLambda, List trades){
  List newTrades = new ArrayList<>();
  
  for(Trade trade : trades){
     if(tradeLamda.check(trade)){
        newTrades.add(trade);
     }
  }
   return newTrades;
}
filterTrades 메소드는 newTrades라는 ArrayList 객체를 만들고 Trade들은 컬렉션 안에서 1 대 1로 순회하면서 만일 입력값이 람다에 의해 작성된 판별식을 만족하는 경우 해당 trade객체는 newTrades(ArrayList)에 누적된다. 만족하지 않는 경우는 버려진다. 좋은 점은 lamda표현식을 파라미터로 전달할 수 있기 때문에 아래와 같은 방식으로 하나의 filterTrades 메소드를 이용해서 다른 대상에 대한 filter를 적용할 수 있다.
// Big trades function is passed
List bigTrades = 
  client.filterTrades(bigTradeLambda, tradesCollection);

// "BIG+NEW+ISSUER" function is passed
List bigNewIssuerTrades = 
  client.filterTrades(issuerBigNewTradeLambda, tradeCollection);

// cancelled trades function is passed
List bigNewIssuerTrades = 
  Client.filterTrades(cancelledTradesLamda, tradesCollection);
함수 라이브러리

함수형 인터페이스와 람다식을 통해 재사용 가능한 많은 함수형 요구사항이 있었다. 이에 대응하기 위해서 자바 8에서는새로운 패키지를 만들었는데 이 패키지의 이름은 java.util.function이다. 좀 전의 우리의 예제들 같은 경우에는 Itrade 인터페이스를 만들어서 비즈니스 로직( Trade)에 유효성을 판별하지만 다른 객체 또는 다른 조건을 이용해서 테스트할 수 있다. 꼭 Trade를 안 써도 상관은 없다. 예를 들면 내가 찾고자 하는 직원이 긴 명부 안에 존재하는 직원인지, 당신이 타려고 하는 기차가 런던에서 파리까지 정시에 출발하는지, 오늘 날씨가 좋은지 나쁜지를 모든 요구사항에 대해서 참 거짓으로 판별할 수 있다. 마찬가지로 이러한 일반적인 사례 중에 계좌에 대해서도 사용할 수 있다. java.util.function 패키지의 predicate를 이용하면 정확히 우리가 Itrade로 하는 것( - 입력값이 맞는지 틀리는지 체크하는 것) 직접 구현하지 않고 미리 jdk에 추가된 인터페이스들을 이용해서 요구사항을 처리할 수 있다.

[java.util.Predicate]

조건을 체크하는 함수가 필요하다면 Predicate 인터페이스를 사용해보자. predicate는 하나의 값을 입력받아 평가하고 boolean 값을 리턴하는 함수형 인터페이스이다. test라는 하나의 메소드를 갖고 있고 test 메소드는 boolean을 리턴한다. 인터페이스에 정의된 genericType T는 타입에 대해서 체크해서 받아들이겠다는 의미이다.
@FunctionalInterface
public Interface Predicate{
  boolean test(T t);
}
우리가 알고 있는 람다에 대한 지식과는 조금 거리가 있어보인다. 람다를 이용해서 predicate를 표현한 코드는 아래와 같다.
// A large or cancelled trade (this time using library function)!
Predicate largeTrade = (Trade t) -> t.isBigTrade();
 
// Parenthesis and type are optional
Predicate cancelledTrade = t -> t.isCancelledTrade();
 
// Lambda to check an empty string
Predicate emptyStringChecker = s -> s.isEmpty();
 
// Lambda to find if the employee is an executive
Predicate isExec = emp -> emp.isExec();
이 함수들의 호출방식은 우리가 해왔던 Itrade와 유사하다. Predicate들은 Itrade처럼 값을 체크하고 boolean 타입으로 참 거짓을 리턴한다.
// Check to see if the trade has been cancelled
boolean cancelledTrade = cancelledTrade.test(t);
 
// Check to find if the employee is executive
boolean executive = isExec.test(emp);
[java.util.Function]

Function 인터페이스는 하나의 값을 입력받아서 어떤 값을 리턴하기 위한 목적을 가지고 있는 인터페이스이다. T타입의 인자를 입력받아서 결과로 R 타입을 반환한다. 파라미터는 apply method를 통해서 전달할 수 있다. 실제 코드는 다음과 같다.
@FunctionalInterface
public Interface Function{
 R apply (T t);
}
우리는 Function을 참 거짓 판별 뿐만 아니라 타입 변환을 위해서 쓸 수도 있다. 온도를 섭씨에서 화씨로 전환하듯이 문자열에서 정수형으로 변환하듯이 기타 등등
// convert centigrade to fahrenheit
Function centigradeToFahrenheitInt = x -> new Double((x*9/5)+32);
 
// String to an integer
Function stringToInt = x -> Integer.valueOf(x);
 
// tests
System.out.println("Centigrade to Fahrenheit: "+centigradeToFahrenheitInt.apply(centigrade))
System.out.println(" String to Int: " + stringToInt.apply("4"));
function generic에 두 개의 파라미터 타입 String과 Integer를 확인했다면 String 타입의 값을 입력받아서 Integer 타입으로 리턴한다는 걸 짐작할 수 있을 것이다. 좀 더 복잡한 요구사항을 고려해보면, trade의 리스트를 얻기 위해 trade의 수량을 측정할 때 function은 아래와 같이 표현할 수 있다.
// Function to calculate the aggregated quantity of all the trades - taking in a collection and returning an integer!
Function,Integer> aggegatedQuantity = t -> {
  int aggregatedQuantity = 0;
  for (Trade t: t){
    aggregatedQuantity+=t.getQuantity();
  }
  return aggregatedQuantity;
};
수집은 Stream API를 사용하면 좀 더 유려하게 표현할 수 있다.
// Using Stream and map and reduce functionality

aggregatedQuantity =
  trades.stream()
  .map((t) -> t.getQuantity())
  .reduce(0, Integer::sum);

// Or, even better
aggregatedQuantity =
  trades.stream()
  .map((t) -> t.getQuantity())
  .sum();
[다른 함수들]

자바 8은 consumer, supplier와 같은 특별한 함수형 인터페이스들도 있다. consumer는 입력만 받고 결과를 리턴하지 않는 인터페이스이다.
@FunctionalInterface
public interface Consumer{
  void accept(T t);
}
이것은 지속적인 직원들, 집을 돌보는 일련의 작업들의 호출, 정기적인 email newletter와 같은 경우에 주로 사용된다. Supplier interface는 이름에서 알 수 있듯이 consumer와 다르게 결과를 리턴한다. 해당 메소드 시그니처는 아래와 같다.
@FunctionalInterface
public interface Supplier {
  T get();
}
예를 들면, 데이터베이스로부터 결과를 획득할 때, 참조된 데이터를 로딩할 때, 기본 식별자로 학생리스트를 만들때 등 어떤 결과를 표현하고자 하는 모든 경우에 Supplier 인터페이스를 사용할 수 있다.

자바 8은 특정 케이스에 특화된 버전들의 함수도 제공한다. 예를 들면 unaryOperator 인터페이스는 같은 타입에 대해서만 동작하는 함수형 인터페이스이다. 그래서 같은 타입을 인지하고 있는 경우에는 function 대신 unaryOperator를 쓸 수 있다.
// Here, the expected input and return type are exactly same.
// Hence we didn"t use Function, but we are using a specialized sub-class
 
UnaryOperator toLowerUsingUnary = (s) -> s.toLowerCase();
Function이 하나의 제너럴 타입과 함께 선언된 걸 확인해보자. 이 경우에 양쪽의 타입이 같기 때문에 Function 대신 unarayOPerator를 사용했다. 조금 더 살펴보면 java.util.function 패키지에 있는 인터페이스들은 원시형 데이터의 표현 또한 지원한다. LongUnaryOperator, IntUnaryOperator 등 long은 long으로 double은 double로 Function 패키지는 IntPredicate 또는 LongConsumer,. BooleanSupplier와 같은 많은 특수한 case들을 수용할 수 있는 함수들 또한 가지고 있다. 예를 들면 IntPredicate는 integer 값을 표현하는 함수이고 LongConsumer는 longvalue를 입력받고 결과를 리턴하지 않는다. 그리고 BooleanSupplier는 boolean value를 지원한다. Function 패키지에는 이처럼 과다하게 특수화된 케이스가 매우 많아서 api를 깊게 이해하고 사용하길 권장한다.

2개의 인자 함수

지금까지 우리는 하나의 입력값만 받는 함수들을 다뤘다. 여기에서는 2개의 인자를 입력받는 경우에 대해서 다룬다. 예를 들면, 함수가 2개의 값을 입력받을 것으로 예상되는 경우 그리고 결과가 이전과는 달리 2개의 입력값의 연산으로 반환되는 경우 이런 경우에는 두 개의 함수를 입력받는 BitPredicate, BitConsumer, BitFunction와 같은 함수형 인터페이스를 사용하는 것이 적절하다.

이 메소드의 시그니처는 이해하기 매우 쉽게 되어있다. 함수는 3가지의 타입을 가지고 있다. T, U, R 처음 2개의 인자는 입력인 반면 마지막은 리턴되는 결과다.
@FunctionalInterface
public interface BiFunction {
  R apply(T t, U u);
}
완성도를 위해서 아래의 BitFunction 사용 코드를 보자. 2개의 Trade를 입력받아서 trade 수량의 합을 만든다. input 타입은 trade이고 리턴타입은 Integer이다.
BiFunction sumQuantities = (t1, t2) -> {
  return t1.getQuantity()+t2.getQuantity();
};
2개의 인자를 다루는 다른 함수를 보자. BitPredicate를 이용해서 2개의 인자를 입력받고 하나의 boolean 값을 리턴하도록 할 수 있다.(놀라지 마시라!)
// Predicate expecting two trades to compare and returning the condition"s output
BiPredicate isBig = (t1, t2) -> t1.getQuantity() > t2.getQuantity();
이미 지금 추측했듯이 2개의 입력 함수는 특화되어 있다. 예를 들면 같은 타입의 연산 같은 기능들은 BinaryOperator function에 있다. BinaryOperator 은 BitFunction을 상속했다. 다음 예제에서 BinaryOperator를 보자. 이 예제에서는 2개의 Trade를 입력 받아서 병합한다(기억하자. BinaryOperator는 BitFunction의 특수케이스이다).
BiFunction tradeMerger2 = (t1, t2) -> {
  // calling another method for merger
  return merge(t1,t2);
};
 
// This method is called from the lambda.
// You can prepare a sophisticated algorithm to merge a trade in here
private Trade merge(Trade t1, Trade t2){
  t1.setQuantity(t1.getQuantity()+t2.getQuantity());
  return t1;
}
우리는 타입을 선언할 때 세 개의 타입을 넘기지 않았다. (모든 input , output 타입이 같을 것이라고 예상했기 때문에) 또한 실제로직 전달은 BitFunction이 BinaryOperator보다 더 나은 것 같다. 왜냐면 BinaryOperator가 BitFunction을 상속했기 때문이다.

지금까지 우리는 함수들 그리고 함수형 인터페이스들을 살펴봤다. 이제 인터페이스에 대해서 말해보고 싶다. 자바 8이전 버전의 인터페이스는 추상적인 것들이었다. 인터페이스에서는 선언만 할 뿐 구현을 할 수 없었다. 자바 8에서는 실질적인 구현이 가능하도록 인터페이스를 다시 만들었고 이를 버추얼 메소드(virtual Method)라고 한다.

비추얼 메소드

자바 라이브러리의 여정의 시작은 단순한 인터페이스였다. 시간이 흐르고 그 라이브러리들은 functional programing에 대해서도 대응이 가능하도록 성장하고 진화하길 기대했다. 그러나 자바 8 이전 버전의 interface에서는 불가능한 일이었다. interface는 그저 단단한 돌처럼 정의되고 선언될 뿐이었다. 그렇게 할 수 없었던 분명한 이유들 중 가장 큰 이유 중 하나는 하위 호환성을 유지하는 것이다. 이미 많은 java 버전들이 배포되어있기 때문에 하위호환성을 쉽게 포기할 수 없었다. 그래서 람다가 java 언어에 추가된 것은 멋지다. 람다를 추가하는 것은 하위 호환성을 망가뜨리지 않을 수 있기 때문이다. 하지만 만일 기존 api를 사용할 수 없다면 람다를 지원하기 위해서 interface를 진화시켜야 했다. 어떻게 람다를 지원하고 기존 API에서 람다를 사용 가능하게 하면서 호환성을 잃지않게 할 수 있을까? 이 요구사항은 자바 API 개발자들을 전례없이 압박했다. virtual extension method 또는 default Methods를 인터페이스들로서 제공하기 전까지는 이것들의 의미는 우리가 우리의 인터페이스에 구현 메소드들을 만들 수 있음을 의미한다. virtualMethod는 구체적인 메소드 작성을 허용한다. 예를 들어 collection API를 보면 람다가 어떻게 향상시키고 발전시켰는지 알 수 있다. 간단한 예제로 default method를 이용한 예제를 보자. 모든 component는 이름과 생성된 날짜를 가지고 인스턴스화된다고 가정했을 때 이름과 날짜에 대한 구체적인 구현은 없는 경우 기본적으로 interface로부터 상속받는다. 우리의 예제에서 Icomponent 인터페이스는 default method를 정의했다.
@FunctionalInterface
public interface IComponent {
 
  // Functional method - note we must have one of these functional methods only
  public void init();
 
  // default method - note the keyword default
  default String getComponentName(){
    return "DEFAULT NAME";
  }
  // default method - note the keyword default
  default Date getCreationDate(){
    return new Date();
  }
}}
예제코드를 통해서 알 수 있듯이 default 예약어를 이용해서 추상 메소드가 아닌 구체적인 메소드를 interface 안에 정의할 수 있다.

[다중 상속(Multiple inheritance)]

다중 상속은 자바에서 새로운 것이 아니다. 자바는 예전부터 타입에 의한 다중 상속을 지원해왔다. 만일 우리가 계층적인 다양한 인터페이스를 구현한 객체를 가진다면 몇 가지 규칙들이 자식 클래스를 구현하는데 도움을 줄 것이다. 기본적인 룰은 구체적인 구현은 인터페이스 상속구현보다 우선한다.

[역자 주]

예를 들어서 동일한 getName 메소드를 가진 인터페이스와 추상클래스를 동시에 상속받는다면 추상클래스의 메소드가 우선권을 가지게 된다.
public class Student extends AbstractClass1 implements Faculity{
  public static void main(String ar[]){
  test();
  }

  public static void test(){
    String name = new Student().getName();
    System.out.println(name);
  }
}

Output: abstract Name


다음 예제를 통해서 계층적인 클래스를 확인할 수 있다.
// Person interface with a concrete implementation of name
interface Person{
  default String getName(){
    return "Person";
  }
}
// Faculty interface extending Person but with its own name implementation
interface Faculty extends Person{
  default public String getName(){
    return "Faculty";
  }
}
person, faculty 양쪽의 인터페이스 모두 default 메소드를 이용해서 name에 대한 구현을 제공한다. 그렇지만 faculty 인터페이스에서는 person 인터페이스를 통해 상속받은 getName 메소드를 오버라이딩해서 Faculty를 리턴하도록 했다. 그래서 student라는 서브클래스가 faculty를 상속받으면 Student의 getName 메소드는 Faculty name을 출력한다.
// The Student inherits Faculty"s name rather than Person
class Student implements Faculty, Person{ .. }
 
// the getName() prints Faculty
private void test() {
  String name = new Student().getName();
  System.out.println("Name is "+name);
}

output: Name is Faculty
여기에 진짜 중요한 포인트가 있다. 만약 Faculty 클래스가 person을 상속하지 않으면 어떻게 될까? 이 경우에는 student는 name 속성에 대한 상속을 양쪽으로부터 구현해야 하기 때문에 컴파일러가 괴롭게 된다.

[역자 주] 실제로 Faculty가 person을 상속하지 않으면 메소드 중복으로 인식해서 오류가 발생한다.
Description Resource Path Location Type
Duplicate default methods named getName with the parameters () and () are inherited from the types Faculity and Person


우리의 컴파일러를 행복하게 하기 위해서 이 경우에는 구체적인 구현체를 우리 스스로 제공해야 한다. 만일 슈퍼타입의 행위를 상속받기 원한다면 아래와 같이 명시적으로 처리할 수 있다. 양쪽에 getName이 있어서 혼란이 옴으로 super를 사용한다.

Person.super.getName(); 이렇게 동일한 메소드명을 갖고 있는 상태에서 상속하지 않은 채로 상위클래스의 메소드를 사용하고자 할 경우에는 클래스명 .super를 이용해서 사용할 수 있다. 예를 들어 Person의 이름을 출력하고자 할 경우에는 Person.super.getName() faculty는 Faculty.super.getName()과 같이 하면 된다.
interface Person{ .. }

 // Notice that the faculty is NOT implementing Person
interface Faculty { .. }
 
// As there"s a conflict, out Student class must explicitly declare whose name it"s going to inherit!
class Student implements Faculty, Person{
  @Override
  public String getName() {
    return Person.super.getName();
  }
}
메소드 레퍼런스(Method references)

람다표현식에서 우리는 이미 기존 클래스 또는 super class의 메소드 호출을 살펴봤다. method 그리고 클래스 레퍼런스는 유용하다. 여기 자바 8에 소개된 새로운 lambda를 더욱 간결하게 표현해줄 특성들이 있다. 메소드 레퍼런스들은 메소드의 shortcut을 가진다. 예를 들면 2개 integer를 입력받는 메소드를 가진 클래스가 있다.
public class AddableTest {
  // Add given two integers
  private int addThemUp(int i1, int i2){
    return i1+i2;
}
addThemUp을 MethodReference를 이용해서 IAddable을 구현해보자. this::addThemUp에서 this는 AddableTest 클래스의 인스턴스를 나타내고 ::뒤에는 이미 정의된 메소드명을 () 를 사용하지 않고 기술한다. :: 를 사용하면 이제 보다 간결하게 다른 클래스의 static 메소드를 사용할 수 있다.
// Class that provides the functionality via it"s static method
public class AddableUtil {
  public static int addThemUp(int i1, int i2){
    return i1+i2;
  }
}
 
// Test class
public class AddableTest {
  // Lambda expression using static method on a separate class
  IAddable addableViaMethodReference = AddableUtil::addThemUp;
  
}
우리는 생성자 또한 쉽게 호출할 수 있다. 예를 들면 Employee::new 또는 Trade::new 처럼 ::를 사용해서 보다 간결하게 표현할 수 있다.

요약

이 포스트에서 우리는 함수형 인터페이스와 함수들을 배웠다. 우리는 다양한 특별한 함수들로 나눠지는 Consumer, Predicate, Supplier 등을 배웠다. 또 virtual Method 그리고 method references 에 대해서도 다뤘다.

*****

함수형 Interface 정리

Interface명 Arguments Return Value
Predicate T boolean
Consumer T Void
Function T R
Supplier None T
unaryOperator T T
BinaryOperator (T , T) T
TAG :
댓글 입력
자료실