고차원 함수
함수형 프로그래밍에서는 함수를 일반값처럼 취급하여 인수로 전달하거나 결과로 반환하거나 자료구조에 저장할 수 있다. 이러한 행위가 가능한 함수를 일급 함수라고 부른다.
자바 8은 이전 버전들과 달리 일급 함수를 지원하여 :: 연산자로 메서드 참조를 만들거나 람다 표현식을 이용해 일급 함수로 사용할 수 있다.
Copy Function < String , Integer > strToInt = Integer :: parseInt;
고차원 함수 란 함수를 인수로 받아 다른 함수를 반환하는 함수이다.
아래와 같이 Comparator의 comparing 메서드는 Function 형태의 함수를 입력받아 Comparator 형태의 함수를 반환한다.
Copy Comparator < Apple > c = Comparator . comparing (Apple :: getWeight);
부작용을 포함하는 함수를 사용한다면 일관되지 않은 결과나 race condition이 발생할 수 있다. 따라서 고차원 함수 역시 인수가 부작용을 포함하는 함수일 가능성을 대비하여 정확히 문서화하는 것이 좋다.
커링
x, y 라는 두 인수를 받는 f 함수를 한 개의 인수를 받는 g 함수로 대체하는 기법이다.
즉, f(x, y) = (g(x))(y) 가 성립해야 한다.
예를 들어, 애플리케이션에서 국제화를 지원하면서 단위 변환 문제가 발생할 수 있다.
기존 자바 버전에서 사용하던 방식으로 아래와 같은 메서드를 정의해 사용할 수 있다. x는 구체적인 변수 값, f, b는 변환을 위한 공식 값이다.
Copy static double converter( double x , double f , double b) {
return x * f + b;
}
다음은 커링을 이용해 단위 변환 전용 메서드를 쉽게 만들 수 있는 방식이다. 변환 공식에 의한 f, b 값을 입력받는 팩토리 메서드를 만들고, 이 메서드에 의해 나온 변환기를 사용하는 방식이다.
이를 통해 변환 로직을 재활용할 수 있으며 다양한 변환 요소로 다양한 함수를 만들 수 있다.
Copy static DoubleUnaryOperator curriedConverter( double f , double b) {
return ( double x) -> x * f + b;
}
void test() {
DoubleUnaryOperator convertCtoF = curriedConverter( 9 , 0 / 5 , 32 ) ;
DoubleUnaryOperator convertUSDtoGBP = curriedConverter( 0.6 , 0 ) ;
DoubleUnaryOperator convertKmtoMi = curriedConverter( 0.6214 , 0 ) ;
double gbp = convertUSDtoGBP . applyAsDouble ( 1000 );
}
영속 자료구조
함수형 메서드에서는 참조 투명성에 위배되지 않고 인수를 결과로 단순히 매핑하기 위해 전역 자료구조나 인수로 전달된 구조를 갱신할 수 없게 되어있다.
만약 함수 내에서 외부의 자료구조를 갱신하게 된다면 해당 자료구조를 참조하던 다른 함수에서 혼란에 빠질 것이다.
따라서 함수형 프로그래밍에서는 이러한 함수가 생기지 않도록 제한한다.
아래 예제에서는 TrainJourney라는 클래스가 다음 행선지인 TrainJourney 클래스를 이어서 갖고 있다. append 메서드는 입력 인자(기존 자료구조)를 변경하는 형태가 아니라 새로운 객체를 만들어 반환값을 만드는 형태로 동작한다.
Copy class TrainJourney {
public int price;
public TrainJourney next;
public TrainJourney ( int price , TrainJourney next) {
price = price;
next = next;
}
static TrainJourney append ( TrainJourney a , TrainJourney b) {
return a == null ? b : new TrainJourney( a . price , append( a . next , b)) ;
}
}
이렇게 저장된 값이 외부에 영향을 받지 않는 영속 자료구조를 다루는 함수를 사용해 기존 구조를 변화시키지 않을 수 있다.
주의할 점은 함수에서 반환된 자료구조를 바꾸지 않아야 한다는 것이다. 만약 변경하게 된다면 의도치 않은 갱신이 발생할 수 있다.
LazyList
스트림은 단 한번만 소비할 수 있으므로 재귀적으로 정의할 수 없다.
스트림에 일련의 연산을 적용하면 바로 수행되지 않고 최종 연산을 적용해 실제 계산을 해야할 때 수행된다.
게으른 특성으로 인해 각 연산 별로 스트림을 탐색할 필요 없이 한 번에 여러 연산을 처리할 수 있다.
이를 이용해 함수를 자료구조에 저장해 사용하지 않은 상태로 저장하는 LazyList를 구현할 수 있다. LinkedList의 원소는 메모리에 존재하지만, LazyList의 원소는 함수가 호출되어야 생성될 것이다.
Copy class LazyList < T > {
final T head;
final Supplier < LazyList < T >> tail;
public LazyList ( T head , Supplier < LazyList < T >> tail) {
this . head = head;
this . tail = tail;
}
public T head () {
return head;
}
public LazyList < T > tail () {
return tail . get ();
}
public boolean isEmpty () {
return false ;
}
public MyList < T > filter ( Predicate < T > p) {
return isEmpty() ?
this :
p . test ( head() ) ?
new LazyList <>( head() , () -> tail() . filter (p)) :
tail() . filter (p);
}
}
아래와 같이 계속해서 증가하는 수를 반환하는 Supplier를 입력하여도 모든 수가 미리 계산되는 것이 아닌 필요한 시점에 생성되는 것을 확인할 수 있다.
Copy public static LazyList< Integer > from( int n) {
return new LazyList < Integer >(n , () -> from(n + 1 ) );
}
void test() {
LazyList < Integer > numbers = from( 2 ) ;
int two = numbers . head ();
int three = numbers . tail () . head ();
int four = numbers . tail () . tail () . head ();
System . out . println (two + " " + three + " " + four); // 2 3 4
}
아래와 같이 소수를 담는 LazyList를 생성하는 메서드를 만들고, LazyList의 tail 메서드가 호출될 때마다 새로운 소수가 계산되도록 할 수 있다.
Copy public static LazyList< Integer > primes ( LazyList< Integer > numbers) {
return new LazyList <>(
numbers . head () ,
() -> primes( numbers . tail() . filter(n -> n % numbers . head() != 0 ))
);
}
void test() {
LazyList < Integer > list = primes(from( 2 )) ; // 2 이상의 소수를 담는 리스트
while ( ! list . isEmpty ()) {
System . out . println ( list . head ());
list = list . tail ();
}
}
패턴 매칭
아래와 같이 숫자를 나타내는 Number 클래스가 있고 연산을 나타내는 BinOp 클래스가 있다고 한다. 이 때 new Bin("+", new Number(5), new Number(0))
는 Number(5)
로 단순화할 수 있다.
Copy class Expr { ... }
class Number extends Expr { ... }
class BinOp extends Expr {
String opname;
Expr left;
Expr right;
public Expr accept ( SimpiftExprVisitor v) {
return v . visit ( this );
}
}
방문자 디자인 패턴이란 지정된 데이터 형식의 인스턴스를 입력받고 모든 멤버에 접근하는 방식이다.
BinOp 클래스에 SimpiftExprVisitor라는 방문자 클래스를 입력받는 accept 메서드를 추가해두고, 해당 방문자에게 본인 클래스를 전달한다. 이를 통해 방문자 클래스는 BinOp 객체를 unwrap하여 사용할 수 있다.
방문자 디자인 패턴의 자료형을 unwrap하는 형태가 패턴 매칭에서도 사용된다.
Copy public class SimpiftExprVisitor {
...
public Expr visit ( BinOp e) {
if ( "+" . equal ( e . opname ) && e . right instanceof Number && ... ) {
return e . left ;
}
return e;
}
}
아래는 BinOp에 0을 더하거나 1을 곱하고 나눌 때 단순화하는 스칼라의 패턴 매칭 코드이다.
Copy def simplifyExpression (expr: Expr) : Expr = expr match {
case BinOp( "+" , e, Number( 0 )) => e //0 더하기
case BinOp( "*" , e, Number( 1 )) => e //1 곱하기
case BinOp( "/" , e, Number( 1 )) => e //1 나누기
case _ => expr //expr을 단순화할 수 없다.
}
위 코드와 비슷한 동작을 자바로 구현하려면 아래와 같이 람다를 함수의 인자로 입력하는 방식으로 구현해야 한다.
patternMatch 메서드의 첫 번째 인자는 Expr가 BinOp일 때 수행되는 판별 메서드 이고, 두 번째 인자는 Expr가 Number 타입일 때 수행되는 메서드 이고, 세 번째 인자는 두 경우 이외일 경우에 수행되는 메서드 이다.
BinOp일 경우에는 0을 더하거나 1을 곱하고 나눌 때 단순화하는 메서드가 수행되어야 한다.
Copy @ FunctionalInterface
interface TriFunction < S , T , U , R > {
R apply ( S s , T t , U u);
}
static < T > T patternMatch(
Expr e ,
TriFunction< String , Expr , Expr , T > binopcase ,
Function< Integer , T > numcase ,
Supplier< T > defaultCase) {
return
(e instanceof Binop) ?
binopcase . apply (((BinOp)e) . opname , ((BinOp)e) . left ,
((Binop)e) . right ) :
(e instanceof Number) ?
numcase . apply (((NUmber)e) . val ) :
defaultcase . get ();
}
public static Expr simplify( Expr e) {
TriFunction < String , Expr , Expr , Expr > binopcase =
(opname , left , right) -> {
if ( "+" . equals (opname)) {
if (left instanceof Number && ((Number) left) . val == 0 ) {
return right;
}
if (right instanceof Number && ((Number) right) . val == 0 ) {
return left;
}
}
if ( "*" . equals (opname)) {
if (left instanceof Number && ((Number) left) . val == 1 ) {
return right;
}
if (right instanceof Number && ((Number) right) . val == 1 ) {
return left;
}
}
return new BinOp(opname , left , right) ;
};
Function < Integer , Expr > numcase = val -> new Number(val) ; //숫자 처리
Supplier < Expr > defaultcase = () -> new Number( 0 ) ;
return patternMatch(e , binopcase , numcase , defaultcase) ;
}
void test() {
Expr e = new BinOp( "+" , new Number( 5 ) , new Number( 0 )) ;
Expr match = simplify(e) ;
System . out . println (match);
}
참조 투명성을 이용한 캐싱
비용이 비싼 함수를 호출하는 경우 데이터를 캐싱하여 추가 오버헤드를 피하고 참조 투명성을 유지할 수 있다.
아래는 HashMap 자료구조에 데이터를 캐싱하는 예제이다.
Copy final Map < Range , Integer > numberOfNodes = new HashMap <>();
Integer computeNumberOfNodeUsingCache( Range range) {
Integer result = numberOfNodes . get (range);
if (result != null ) {
return result;
}
result = computeNumberOfNodes(range) ;
numberOfNodes . put (range , result);
return result;
}
캐싱 데이터가 공유된 가변 상태인 경우 데이터를 추가하거나 찾는 과정에서 동기화 문제가 발생할 수 있다. 이 때 Concurrent 자료구조를 사용할 수 있지만, 동시에 접근할 경우 성능이 크게 저하될 수 있다.
가장 좋은 방법은 함수형 프로그래밍을 사용해 동시성과 가변 상태가 만나지 않도록 하는 것이지만 성능 문제가 발생할 수 있다.
콤비네이터
두 개 이상의 함수를 인자로 받아 다른 함수를 반환하는 메서드를 의미한다.
CompletableFuture 클래스의 thenCombine 메서드는 CompletableFuture와 BiFunction 을 입력받아 새로운 CompletableFuture를 생성한다.
Function 인터페이스에서 제공하는 compose 메서드를 통해 두 함수를 순차적으로 적용할 수 있다.
Copy default < V > Function< V , R > compose( Function<? super V , ? extends T > before) {
Objects . requireNonNull (before);
return ( V v) -> apply( before . apply(v)) ;
}
입력 인자에 2를 곱하는 작업을 n 만큼 반복하는 repeat 메서드를 구현하여 내부 반복을 수행하는 동작을 정의할 수 있다.
Copy static < A > Function< A , A > repeat( int n , Function< A , A > f) {
return n == 0 ? x -> x
: compose(f , repeat(n - 1 , f)) ;
}
void test() {
System . out . println ( repeat( 3 , ( Integer x) -> 2 * x) . apply ( 10 ); // 80
}
Last updated 7 months ago