프로그래밍언어 자바

9. 함수형 프로그래밍과 람다


이번 강좌에서는 함수형 프로그래밍 구문인 람다함수형 인터페이스 를 배우고 자바에서 기본적인 함수형 프로그램을 작성하는 방법을 배웁니다.

이 강의를 통해 람다와 함수형 인터페이스 그리고 함수형 프로그래밍이 적용된 새로운 자바 API들을 이해하고 사용할 수 있습니다.



01: 함수형 프로그래밍(FP: Functional Programming)

함수형 프로그래밍(FP)은 순수 함수(pure function)를 조합하고 공유 상태(shared state), 변경 가능한 데이터(mutable data) 및 부작용(side-effects) 을 피하는 기본 원칙에 따라 소프트웨어를 구성하는 프로그래밍 패러다임 입니다.

객체지향은 동작하는 부분을 캡슐화해서 이해할 수 있게 하고, 함수형 프로그래밍은 동작하는 부분을 최소화해서 코드 이해를 돕는다. (마이클 페더스‘레거시 코드 활용 전략’)

함수형 프로그래밍은 명령형이 아닌 선언적 방식으로 구현되며 흐름 제어를 명시적으로 기술하지 않고 프로그램 로직이 표현된다는 것을 의미 합니다.

1급 객체(First object, First class citizen)

1급 함수라고도 하며 보통 자바스크립트를 배울때 많이 나오는 개념이며 함수형 프로그래밍의 전제 조건이기도 합니다. 일반적으로 다음과 같은 조건을 만족하는 객체를 말합니다.

자바스크립트의 경우 함수는 객체 이므로 함수가 1급 객체가 되는 것이며 자바의 경우 함수형 인터페이스(추상메서드가 하나인 인터페이스)를 통해 구현이 가능합니다.

함수형 프로그래밍 조건

일반적으로 함수형 프로그래밍에서는 다음 세가지 조건을 만족시켜야 합니다.

순수 함수(pure function)

순수 함수란 같은 입력에 대해 항상 같은 출력을 반환하는 함수로 다음과 같은 조건을 만족하는 함수를 말합니다. 멀티쓰레드에서도 안전하고 병렬처리 및 계산도 가능합니다.

고차함수(High Order Function)

1급 함수의 서브셋으로 다음 조건을 만족하는 함수를 말합니다.

익명 함수(Anonymous function)

이름이 없는 함수를 말하며 람다식으로 표현되는 함수 구현을 말합니다.

합성 함수(function composition)는 새로운 함수를 만들거나 계산하기 위해 둘 이상의 함수를 조합하는 것을 말하는 것으로 데이터가 흐르도록 일련의 파이프라인을 형성하는 개념 입니다. 함수형 프로그램은 작은 단위의 순수 함수들로 구성되어 있기 때문에 이 함수들을 연속적 혹은 병렬로 호출해서 더 큰 함수를 만드는 과정으로 프로그램이 구성되어야 합니다.

실제 구현상에는 메서드 체이닝 방식으로 함수들을 연결해 사용하는 형태로 나타 납니다.


02: 람다(Lamda)

람다는 λ라고 표기하며 원래는 수학기호로 1930년대 수학자 알론소 처치의 람다 계산식에서 시작되었다고 합니다. 최신의 프로그램언어에서 사용되는 람다식 혹은 람다함수는 함수형 언어의 특징에서 나온것으로 나중에 한번이상 실행할 수 있는 코드블록을 말하며 실제 구현에는 익명 함수형태로 사용 됩니다.

기본적으로 함수의 구조로 되어 있으며 자바에서는 ->와 같이 화살표 형태의 기호를 이용해 매개변수를 함수 바디로 전달하는 형태를 취합니다.

( parameters ) -> expression body       // 인자가 여러개 이고 하나의 문장으로 구성
( parameters ) -> { expression body }   // 인자가 여러개 이고 여러 문장으로 구성
() -> { expression body }               // 인자가 없고 여러 문장으로 구성
() -> expression body                   // 인자가 없고 하나의 문장으로 구성

장점

단점

최신언어 지원현황

사용 예

다음 예는 1,2,3 세개의 값을 가지고 있는 배열의 각 원소값에 자기자신을 곱한 값을 출력하는 코드 입니다. 람다를 사용하지 않을 경우 배열을 만들고 for 문의 이용해 처리해야 하는 과정을 거쳐야 합니다.

Arrays.asList(1,2,3).stream()
	.map(i -> i*i)
	.forEach(System.out::println);

// 실행결과 -> 1 4 9

03: 함수형 인터페이스

함수형 인터페이스란 단일 추상 메서드를 가지는 인터페이스를 말합니다.

일반적인 인터페이스란 객체지향에서 배운것 처럼 객체를 추상화 하기 위해 사용하며 상수와 추상메서드로만 구현되는 형태를 말합니다. 예를 들어 다음과 같은 인터페이스가 있다고 가정하고 몇가지 구현 예를 통해 함수형 인터페이스 개념을 정리해 보겠습니다.

interface MyInterface {
	void printMsg();
}

일반적인 인터페이스 구현방식

이러한 인터페이스는 구현 클래스를 만들고 만들어진 클래스의 인스턴스를 생성해 사용하는 과정을 거치게 됩니다.

class MyClass implements MyInterface {
	void printMsg(String msg) {
		System.out.println(msg);
	}
}

class MyApp {
	MyApp() {
		MyClass mc = new MyClass();
		print(mc);
	}
	...
	void print(MyInterface arg) {
		arg.printMsg("Hello");
	}
}

익명의 이너클래스를 이용한 인터페이스 구현방식

위의 예제를 보면 MyApp 생성자에서 MyClass 인스턴스는 한번만 사용되고 있으며 나아가 MyClass 구현이 다른곳에서는 사용되지 않는다고 한다면 다음과 같이 익명의 이너클래스(Anonymous Inner Class)를 사용하는 형태로 코드 간소화가 가능합니다.

class MyApp {
	MyApp() {
		print(new MyInterface() {
			void printMsg(String msg) {
				System.out.println(msg);
			}
		});
	}
	...
	void print(MyInterface arg) {
		arg.printMsg("Hello");
	}
}

함수형 인터페이스를 이용한 구현방식

만일 인터페이스에 추상 메서드가 하나라고 하면 인터페이스 구현 자체가 하나의 메서드 구현만을 의미하므로 마치 함수와 같은 개념으로 이해할 수 있으며 람다 표현식을 이용해 함수를 구현할 수 있게 됩니다. MyInterface 는 추상메서드가 하나이므로 함수형 인터페이스로 볼 수 있습니다.

class MyApp {
	MyApp() {
		print(msg -> System.out.println(msg));
	}
	...
	void print(MyInterface arg) {
		arg.printMsg("Hello");
	}
}
Note: 함수형 인터페이스에 @FunctionalInterface 애너테이션을 사용할 수 있습니다. 굳이 애너테이션이 아니더라도 추상 메서드가 하나인 인터페이스는 함수형 인터페이스로 처리 됩니다. 다만 애너테이션을 사용할 경우 코드상에 명시되어 확인이 쉽고, 컴파일러가 단일 추상 메서드만을 가지고 있는지 검사하기 때문에 혹시 모를 구현상의 오류를 방지해 주기도 합니다. 또한 javadoc 페이지에 해당 인터페이스가 함수형 인터페이스임을 알리는 문장을 포함시키는 역할을 하기 때문에 가급적 사용하는 것이 좋습니다.

default 와 static 메서드

전통적인 인터페이스에서는 추상메서드와 상수만 지원이 되었지만 함수형 인터페이스 개념이 도입된 이후 추상메서드가 아닌 구현된 메서드를 가질 수 있으며 이경우 default 로 선언하면 됩니다. 또한 static 메서드도 구현이 가능해져 추상클래스를 대체할 수 있게 되었습니다.

interface MyInterface {
	void printMsg();
	default String getMsg() {
		return "msg from interface";
	}
	static void print(String msg) {
		System.out.println(msg);
	}
}

메서드 레퍼런스

다른 코드에 전달하고자 하는 액션을 수행하는 메서드가 이미 존재할 수 도 있는데 예를 들면 앞의 예제에서 print() 의 인자로 전달될 MyInterface의 printMsg() 구현내용은 System.out.println() 이라고 할 수 있습니다. 이 경우 메서드만 전달하는 것이 더 간결하므로 다음과 같이 ::을 사용해 메서드 레퍼런스를 사용할 수 있습니다.

print(msg -> System.out.println(msg));
print(System.out::println);

생성자 레퍼런스

메서드 레퍼런스와 유사하지만 ::new 를 사용합니다. 기본적으로는 메서드 레퍼런스와 같이 메서드에 대한 참조를 전달하는 것이지만 단순히 메서드에 대한 참조가 아니라 새로운 객체를 생성한다는 점에서 차이가 있습니다.

List<String> labels = ...
Stream<Button> stream = labels.stream().map(Button::new);
List<Button> buttons = stream.collect(Collectors.toList());

배열의 경우 Button[] buttons = stream.toArray(Button[]::new)와 같이 사용할 수 있습니다.


04: java.util.function 패키지

함수형 프로그램을 구현하다 보면 구조적으로 비슷한 유형의 함수형 인터페이스가 필요한 경우가 많이 발생 합니다. 이 경우 java.util.function 패키지에 정의된 함수형 인터페이스들을 사용할 수 있습니다. 특히 다음에 배우게 될 Stream API 를 제대로 이해하기 위해서는 다음의 함수형 인터페이스를 잘 알아 두어야 합니다.

[표: 기본 함수형 인터페이스 목록]
종류 추상 메소드 특징 비고
Function 인자도 있고, 리턴값도 있음, 주로 매개값을 연산하고 결과를 리턴 BiFunction
Consumer 인자는 있고, 리턴값은 없음 BiConsumer
Supplier 인자는 없고, 리턴값은 있음  
Operator 인자도 있고, 리턴값도 있음, 주로 매개값을 연산하고 결과를 리턴  
Predicate 인자는 있고, 리턴값은 boolean, 매개값을 조사하고 true/false를 리턴 BiPredicate


이들 함수형 인터페이스는 타입파라미터를 가지는데 각각의 의미는 다음과 같습니다.

T: 첫번째 인자 Type
U: 두번째 인자 Type
R: 리턴 타입

Function

인터페이스 명칭에서부터 알 수 있듯이 전형적인 함수를 지원한다고 볼 수 있습니다. 하나의 인자와 리턴을 가집니다.

Function<String, Integer> f = str -> Integer.parseInt(str);
Integer result = f.apply("10");

Consumer

인자를 받아 소모하고 리턴이 없는 함수형 인터페이스 입니다.

Stream 인터페이스의 forEach 메서드는 void forEach(Consumer<? super T> action) 로 정의되어 있으며 Cosumer 함수형 인터페이스를 인자로 가지고 있습니다.

Arrays.stream(strArr).sorted().forEach(str -> System.out.println(str));

Supplier

인자가 없으며 리턴타입만 존재하는 형태 입니다. 순수함수에서 결과를 바꾸는건 오직 입력 뿐인데 입력이 없다는 것은 내부에서 랜덤 함수 같은것을 쓰는게 아닌이상 항상 같은 것을 리턴한다는 것을 알 수 있습니다.

Supplier<String> s = () -> "msg from supplier";
String result = s.get();

Operator

Function과 동일한 형태의 applyXXX라는 메소드를 가지고 있습니다.

UnaryOperator<String> u = str -> str + " operator";
String result = u.apply("msg from unary operator");

BinaryOperator<String> b = (str1, str2) -> str1 + " " + str2;
String result = b.apply("msg from", "binary operator");

Predicate

하나의 인자와 boolean 타입의 리턴값을 가지고 있습니다.

Predicate<String> p = str -> str.contains("msg");
boolean result = p.test("hello"); // false

Comparator

보통 객체간 우선순위를 비교할때 Comparable 인터페이스를 사용하는데 그때 그때 적용할 로직을 1회성으로 구현하는데 많이 사용했습니다. Comparator는 간단하게 Comparable 인터페이스를 대신해 사용할 수 있습니다.

Comparator<String> c = (str1, str2) -> str1.compareTo(str2);
int result = c.compare("xxx", "yyy");  //-1

참고 자료