객체 지향 프로그래밍은 동작부를 캡슐화해 코드를 이해하기 쉽게 만든다.
함수형 프로그래밍은 동작부를 최소화해 코드를 이해하기 쉽게 만든다. - 마이클 패더스
여기서는 Java 8의 등장으로 발전한 함수형 프로그래밍의 핵심 개념을 소개한다.
// Before
class Calculator {
Map<Double, Double> values = new HashMap<>();
Double square(Double x) {
Function<Double, Double> squareFunction = new Function<>() {
@Override
public Double apply(Double value) {
return value * value;
}
};
return values.computeIfAbsent(x, squareFunction);
}
}
computeIfAbsent()
메서드 구현에는 다음의 번거로움이 존재한다.
Function<Double, Double>
인터페이스를 구현해야 한다.apply()
메서드를 포함하는 인스턴스를 필요로 한다.// After
class Calculator {
Map<Double, Double> values = new HashMap<>();
Double square(Double x) {
// 여러 줄로 작성
// Function<Double, Double> squareFunction = factor -> {
// return factor * factor;
// }
// 한 줄로도 가능 (중괄호와 return 문 없음)
Function<Double, Double> squareFunction = factor -> factor * factor;
return values.computeIfAbsent(value, squareFunction);
}
}
자바 8부터는 람다 표현식(lambda expression)으로 익명 클래스를 대체할 수 있다. 람다는 함수형 인터페이스, 즉 단일 추상 메서드(SAM, Single Abstract Method)를 포함하는 인터페이스를 구현한다. 람다를 한 줄로 작성하면 매우 짧고 간결한 글루 코드(glue code)를 완성할 수 있다.
람다를 사용할 땐 여러 줄을 쓰지 말고 <람다 대신 메서드 참조>를 적용하자.
// 타입 정의와 소괄호가 없는 경우
Function<Double, Double> squareFunction = factor -> factor * factor;
// 타입 정의와 소괄호를 넣은 경우
Function<Double, Double> squareFunction = (Double factor) -> factor * factor;
일반적으로 자바는 어떠한 경우든 타입을 명시적으로 표기해야 한다. 하지만 람다 표현식의 매개변수면 대부분 컴파일러 스스로 타입을 알아낼 수 있다. 이를 타입 추론(type inference)이라고 한다.
// Before
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
List<String> names = new ArrayList<>();
for (Supply supply : supplies) {
if (supply.isUncontaminated()) {
String name = supply.getName();
if (!names.contain(name)) {
names.add(name);
}
}
}
return names.size();
}
}
컬렉션 처리에 대해서는 함수형 프로그래밍 방식이 명령형 방식보다 훨씬 읽기 쉽다.
위 코드는 명령형 방식으로 작성되었다. 명령형 방식은 컴퓨터가 “한 줄씩 읽어가면서” 코드를 분석해나가는 흐름으로 동작한다. 하지만 일반적으로 코드가 무엇을 하는지에 관심 있지, 어떻게 목표에 도달하는지는 별 관심이 없다. 크기로 보든 양으로 보든 한 줄씩 읽을 수밖에 없는 코드는 메서드의 의도를 흐린다.
여기에 람다 표현식을 사용하면 코드에서 무엇이 이루어지길 원하는지 확실히 명시할 수 있다.
// After
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
return supplies.stream()
.filter(supply -> supply.isUncontaminated())
.map(supply -> supply.getName())
.distinct()
.count();
}
}
람다를 사용하면 ‘어떻게’를 코딩하는 대신 ‘무엇’만 명시하여 코드의 양을 획기적으로 줄일 수 있다.
stream()
으로 컬렉션을 스트림으로 변환한다.filter()
를 통해 조건에 따라 원소를 필터링한다.map()
으로 특정 타입을 다른 타입으로 매핑한다.distinct()
로 중복을 거르고, count()
로 스트림 내 원소의 수를 세어서 결과로 반환한다.
java.util.stream
패키지의 JavaDoc에는 스트림 API 가이드가 잘 작성되어 있으니 이를 참고하자.
람다는 코드를 획기적으로 줄이지만, 그에 따른 단점도 분명 존재한다.
// Before
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
return supplies.stream()
.filter(supply -> !supply.isContaminated())
.map(supply -> supply.getName())
.distinct()
.count();
}
}
위 코드는 직전의 코드에서 논리 부정을 추가한 코드이다. 람다 표현식 2개는 각각 filter
를 하는 Predicate
, map
을 하는 Function
이다. 각각의 표현식을 한 줄씩 정의하여 코드는 간결해졌지만, 표현식을 변수로 참조하지 않아서 다른 곳에서는 사용할 수 없다. 이로 인해 단위 테스트를 포함한 여러 테스트를 진행할 수 없는 문제가 있다.
다행히 자바의 함수형 프로그래밍에도 이것을 처리할 메서드 참조(method reference) 매커니즘이 있다.
// After
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
return supplies.stream()
.filter(Supply::isContaminated)
.map(Supply::getName)
.distinct()
.count();
}
}
문법이 간단해졌다. 람다 표현식을 메서드 참조로 수정한 결과, 부정을 피하고 코드가 더욱 간소화되었다. 이제 스트림은 미리 정의된(물론 테스트까지 끝난) 메서드를 조합할 뿐이기 때문에 장점이 극대화되었다.
메서드 참조를 사용할 때에는 특수한 ClassName::methodName
형식의 문법을 써야 한다. 참고로 메서드 참조는 매우 유연해서 ClassName::new
형태로 생성자까지 참조할 수 있다. 또한 collect(Collectors.toCollection(TreeSet::new))
처럼 스트림을 컬렉션으로 변환하는 것도 간단하다.
부수 효과는 프로그래밍 관점에서 값을 반환하는 것 외로 부수적으로 발생하는 효과를 말한다. “부가적인”이라는 뜻을 강조하기 위해 부작용이라고도 부른다.
이론상 함수형 프로그래밍에는 부수 효과(side effect)가 없다. 모든 메서드는 입력으로 데이터를 받고, 출력으로 데이터를 생성하는 함수일 뿐이다. 하지만 명령형과 객체 지향 프로그래밍은 항상 부수 효과에 의존한다. 때문에 프로시저나 메서드를 통해 데이터와 상태를 쉽게 바꿀 수 있다.
자바는 여러 스레드 간 부수 효과 발생에 대한 책임을 지지 않는다. 따라서 코드 내 부수 효과를 최소화하기 위해 노력해야 한다.
아래 코드를 살펴보자.
// Before
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
List<String> names = new ArrayList();
Consumer<String> addToNames = name -> names.add(name);
supplies.stream()
.filter(Supply::isUncontaminated)
.map(Supply::getName)
.distinct()
.forEach(addToNames);
return names.size();
}
}
문제는 스트림의 forEach()
부분에서 호출하는 addToNames
에 있다. Consumer
는 람다 표현식 밖에 있는 리스트에 원소를 추가한다. 그리고 바로 이때 부수 효과가 발생한다. 즉 평소에는 코드에 문제가 없지만 동시 실행이 가능하도록 변경하면 문제가 생긴다.
앞서 말했듯 자바는 여러 스레드 간 부수 효과 발생에 대한 책임을 지지 않는다. 또한 람다 표현식을 병렬화하기만 해도 ArrayList
가 thread-safe하기 않기 때문에 코드 오작동을 불러올 수 있다. filter()
와 map()
은 스트림 원소에만 작용하기 때문에 아무런 부수 효과도 일으키지 않지만, 명령형 방식의 코드는 스트림을 종료시키려는 시도로 인해 부수 효과를 불러일으킬 수 있다.
한편 스트림 내부에서는
System.out.println()
을 사용하면 안 된다.println()
은 내부적으로synchronized
를 사용하기 때문에 동기화 과정에서 성능 이슈를 불러일으킬 수 있다.
// After
class Inventory {
List<Supply> supplies = new ArrayList<>();
long countDifferentKinds() {
List<String> names = supplies.stream()
.filter(Supply::isUncontaminated)
.map(Supply::getName)
.distinct()
.collect(Collectors.toList());
return names.size();
}
}
위 코드에서는 부수 효과를 없애고 람다 표현식을 종료하는 방법을 사용하고 있다. 리스트를 직접 만들지 않고, 컬렉션 내 스트림에 남아 있는 각 원소를 collect()
하고 있다.
리스트를 만들기 위해서는 collect(Collectors.toList())
로 스트림을 종료해야 한다. 물론 Collectors.toSet()
으로 Set
을 만드는 등 다른 자료 구조도 사용할 수 있다.
// Refactoring
return supplies.stream()
.filter(Sypply:isUncontaminated)
.map(Supply::getName)
.distinct()
.count();
메서드의 목적에 따라서 종료 연산자를 지정하여 스트림 실행 결과를 바로 반환할 수도 있다. 여기서는 count()
를 사용했다.
지금까지의 내용을 요약해보자. 스트림을 종료시킬 때
forEach()
는 부수 효과를 일으킬 수 있기 때문에 사용을 지양해야 한다. 대신collect()
나reduce()
등을 사용하여 스트림을 종료시키면서 필요한 자료 구조를 반환받도록 하자.
Stream
클래스의reduce()
연산자는 리스트를 하나의 정수 값으로 리듀스하는 역할을 한다.
// Before
class Intentory {
List<Supply> supplies = new ArrayList<>();
Map<String, Long> countDifferentKinds() {
Map<String, Long> nameToCount = new HashMap<>();
Consumer<String> addToNames = name -> {
if (!nameToCount.containsKey(name)) {
nameToCount.put(name, 0L);
}
};
supplies.stream()
.filter(Supply::isUncontaminated)
.map(Supply::getName)
.forEach(addToNames);
return nameToCount;
}
}
위 코드는 앞에서 비교했던 코드의 변형본이다. 단순히 supplies
리스트 내 고유 원소의 수를 세는 대신 남은 제품의 수를 이름별로 묶어서 계산하여 Map<String, Long>
형태로 만든다.
SQL로 따지면
SELECT name, count(*) FROM supplies GROUP BY name
과 같다.
위 코드 역시 앞서 살펴본 바와 같이 문제가 있다. Map<String, Long> nameToCount
를 계산할 때 부수 효과에 크게 의존한다. 또한 코드의 복잡도 역시 이전보다 더 높다.
// After
class Intentory {
List<Supply> supplies = new ArrayList<>();
Map<String, Long> countDifferentKinds() {
return supplies.stream()
.filter(Supply::isUncontaminated)
.collect(Collectors.groupingBy(Supply::getName,
Collectors.counting())
);
}
}
스트림의 결과를 Collection
으로 만들어야 한다면 Collectors
를 사용하면 좋다. 개선된 코드에서는 Collectors.groupingBy()
연산자를 Supply
인스턴스의 스트림에 적용하여 항상 Map
자료구조를 반환하도록 했다.
예제에서는 Supply
객체를 이름별로 그루핑해야 했기 때문에 메서드 참조인 Supply::getName
을 전달했다. 참조를 전달함으로써 결과 Map
의 키 타입, 즉 String
까지 명시했다. 이렇게만 해도 표현식은 Map<String, Collection<Supply>>
와 같은 형태가 된다. 즉 groupingBy()
덕분에 map
연산자는 더 이상 필요하지 않게 되었다.
또한 groupingBy()
호출의 두 번째 매개변수인 Collectors.counting()
에 대해 보면, 한 그룹 내 Supply
인스턴스의 수를 세는데, 이렇게 하면 원하던 대로 이름별 항목 수인 Map<String, Long>
이 결과로 나오게 된다.
즉
collect()
를 사용하는 방법이 훨씬 더 간결하다. 또한 SQL 쿼리처럼 읽혀서 가독성 면에서도 좋다.
(내용 추가 중…)