자바8 버전부터 람다식의 도입으로, 이제 자바는 객체지향언어인 동시에 함수형 언어가 되었다.
// 일반 메서드
int method() {
return (int) (Math.random() * 5) + 1);
}
// 람다식
Arrays.setAll(arr, (i) -> (int) (Math.random() * 5) + 1);
간단히 말해서 메서드를 하나의 식
으로 표현한 것
람다식으로 표현하면 메서드의 이름과 반환 타입이 없어지므로, 익명 함수
라고도 한다.
매개변수 타입과 반환 타입이 없어도 항상 타입 추론
이 가능하다.
메서드의 매개변수로 전달되어지는 것이 가능하고, 메서드의 결과로 반환될 수도 있다. 람다식으로 인해 메서드
를 변수
처럼 다루는 것이 가능해졌다.
식의 끝에는 세미콜론(;)
을 붙이지 않는다.
매개변수가 하나뿐인 경우 괄호()
를 생략할 수 있다. (단, 매개변수 타입이 있을 경우 생략 불가능)
괄호{}안의 문장이 하나일 때는 괄호{}
를 생략할 수 있다. (단, return
문을 작성할 경우 생략 불가능)
💡 람다식이 왜 등장했냐?
함수형 인터페이스의 익명 클래스에 의한 구현을 간결하게 기술하기 위한 것
함수형 인터페이스는 추상 메서드가 단 1개만 정의된 인터페이스를 의미한다.
람다식을 이용해 함수형 프로그래밍을 구현하기 위해 존재한다.
예를 들어 아래와 같은 인터페이스가 있다고 하자.
public interface Calc {
int plus(int x, int y);
}
그러나 인터페이스의 인스턴스를 생성하는 것은 불가능하기 때문에 구현체를 정의하는 것이 일반적이다.
public class Toy implements Calc {
public int plus(int x, int y) {
return x + y;
}
}
public class Main {
public static void main(String[] args) {
Toy t = new Toy();
System.out.println(t.plus(1,2));
}
}
하지만 다음과 같이 익명 클래스를 사용하면 간략하고 에러없이 컴파일이 가능하다.
public class Main {
public static void main(String[] args) {
Calc c = new Calc() {
public int plus(int x, int y) {
return x + y;
}
};
System.out.println(c.plus(1,2));
}
}
익명 클래스는 편리하지만 깔끔한 소스라고 할 순 없다. 여기서 람다식
은 깔끔하지 않은 익명 클래스를 간결하게 쓸 수 있도록 하는 편리한 기능인 것이다.
그러나 람다식을 사용해야 하는 이유는 익명 클래스의 간결함뿐만 아니라, 사실 람다식의 진가는 스트림
을 위한 것이다.
람다식으로 메서드를 간결하게 표현할 수 있었지만, 놀랍게도 람다식을 더욱 간결하게 표현할 수 있는 방법이다.
클래스::메서드
s → System.out.println(s)
를 System.out::println
으로 축약할 수 있다.정적인 메서드 뿐만 아니라, 인스턴스 메서드 또한 메서드 참조로 바꿔서 적을 수 있다.
public class MethodReferenceRunner {
public static void main(String[] args) {
List.of("Ant", "Bat", "Cat", "Dog", "Elephant").stream()
.map(s -> s.length())
.forEach(e -> System.out.println(e));
List.of("Ant", "Bat", "Cat", "Dog", "Elephant").stream()
.map(String::length)
.forEach(System.out::println);
Integer max = List.of(23, 45, 67, 34).stream()
.filter(n -> n % 2 == 0)
.max((n1, n2) -> Integer.compare(n1, n2))
.orElse(0);
Integer max2 = List.of(23, 45, 67, 34).stream()
.filter(MethodReferenceRunner::isEven)
.max(Integer::compare)
.orElse(0);
}
public static boolean isEven(Integer number){
return number % 2 == 0;
}
}
그동안 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문
과 Iterator
를 이용해서 코드를 작성해왔다. 그러나 코드의 가독성과 각 컬렉션 클래스 마다 같은 기능의 메서드들(ex. sort()
)이 중복해서 정의되므로 재사용성이 떨어졌다.
이러한 문제를 해결하기 위해 만든 것이 스트림
이다.
스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데이트 소스가 무엇이든 간에 같은 방식으로 다룰 수 있게 하여 재사용성을 높인다.
public class FunctionalProgrammingRunner {
public static void main(String[] args) {
List<String> list = List.of("Apple", "Banana", "Cat", "Bat", "Dog");
printWithFP(list);
List<Integer> numbers = List.of(1,2,3,4,5);
printWithFPReduce(numbers);
}
// 일반적인 반복 메서드
private static void printBasic(List<String> list) {
for (String s : list) {
System.out.println(s);
}
}
// 람다식 & 스트림(반복문)
private static void printWithFP(List<String> list) {
list.stream()
.forEach(e -> System.out.println(e));
}
// 일반적인 필터 메서드
private static void printBasicWithFiltering(List<String> list) {
for (String s : list) {
if (s.endsWith("at")) {
System.out.println(s);
}
}
}
// 람다식 & 스트림(필터링)
private static void printWithFPFiltering(List<String> list) {
list.stream()
.filter(e -> e.endsWith("at"))
.forEach(e -> System.out.println(e));
}
// 일반적인 누산 메서드
private static void printBasicReduce(List<Integer> numbers) {
int sum = 0;
for (int num : numbers) {
sum += num;
}
System.out.println(sum);
}
// 람다식 & 스트림(누산)
private static void printWithFPReduce(List<Integer> numbers) {
int sum = numbers.stream()
.reduce(0, (n1, n2) -> n1 + n2);
System.out.println(sum);
}
}
이처럼 람다식과 스트림을 활용하여 데이터를 편리하게 다룰 수 있게 되었다.
데이터의 집합(배열이나 컬렉션 등)에 대한 처리를 함수형 프로그래밍(람다식)으로 간결하게 기술하기 위한 새로운 개념이다.
데이터.stream 생성.중간조작 메서드, 종단조작 메서드;
와 같은 식으로 구성된다.
예를 들어 filter()
의 매개변수로 Predicate 클래스를 허용한다고 되어있다.
Predicate는 함수형 인터페이스로 test()
라는 단 1개의 추상 메서드를 가지고 있다.
위에서 설명했듯이, 람다식은 함수형 인터페이스의 추상 메서드를 익명 클래스로 구현한 거랑 다름없다.
사실 람다식 말고 해당 메서드 파라미터로 인터페이스 구현체를 만들어서 넣어도 되는데, 람다식을 통해 가독성을 높이고 편의성을 제공할 수 있다.
class FIFilter implements Predicate<Integer> {
@Override
public boolean test(Integer num) {
return num % 2 == 0;
}
}
class FIForEach implements Consumer<Integer> {
@Override
public void accept(Integer integer) {
System.out.println(integer);
}
}
class FIMap implements Function<Integer, Integer> {
@Override
public Integer apply(Integer num) {
return num * num;
}
}
public class LamdaFIRunner {
public static void main(String[] args) {
List.of(23, 43, 34, 45, 36, 48).stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.forEach(e -> System.out.println(e));
List.of(23, 43, 34, 45, 36, 48).stream()
.filter(new FIFilter())
.map(new FIMap())
.forEach(new FIForEach());
}
}
Stream object는 반드시 한 번의 종단조작 메서드를 불러, 최종적인 결과를 얻는다.
// max, min
List.of(23,12,34,53).stream().max((n1,n2) -> Integer.compare(n1, n2)).orElse(0);
List.of(23,12,34,53).stream().min((n1,n2) -> Integer.compare(n1, n2)).orElse(0);
// collect(toList뿐만 아니라 toSet 등 다른 메서드도 존재)
List.of(23,12,34,53).stream().filter(e -> e % 2 == 1).collect(Collectors.toList());
// reduce(첫번째 인자는 초기값)
List.of(1,2,3,4,5).stream().reduce(0, (n1,n2) -> n1 + n2).get();
맨 뒤에 orElse()
의 존재는 Optional API중 하나이다.
이는 리스트에 혹여나 데이터가 존재하지 않을 경우, null을 반환할텐데 이때 발생하는 Null Point Exception
을 피하기 위한 것이다.
IntStream.range(1,11).map(e -> e * e).boxed().collect(Collectors.toList())
IntStream
은 Stream 타입이 아니다.
즉 타입이 다르기 때문에 스트림으로 바꿔주려면 boxed()
를 추가해야 한다.
Stream object는 0번 이상의 중간조작에 의해 데이터의 변환이나 추출 등을 수행할 수 있다.
// 첫 10개의 정수 제곱 출력
IntStream.range(1, 11).forEach(e -> System.out.println(e * e));
System.out.println();
// 소문자로 출력
List.of("Apple", "Ant", "Bat").stream().map(e -> e.toLowerCase()).forEach(e -> System.out.println(e));
System.out.println();
// 각 요소의 길이 출력
List.of("Apple", "Ant", "Bat").stream().forEach(e -> System.out.println(e.length()));
<T>
스트림 최종 연산의 결과 타입이 Optional인 경우가 있다.
Optional<T>
는 제네릭 클래스로 T타입의 객체를 감싸는 래퍼 클래스이다.
NPE를 편하게 제어하기 위한 자바8
기능이다.
null
체크를 해야하는 번거로움이 있었다.Stream처럼 중간 연산 및 종단 연산을 수행할 수 있다.
생성 메서드
null
일수도 있을 경우 예외(NPE)를 방지하기위해 of
대신 사용한다.중간 연산 메서드
종단 연산 메서드
null
인 경우 NoSuchElementException
발생한다.// Optional 객체 생성하기
Optional<String> optVal1 = Optional.of("abc");
Optional<String> optVal2 = Optional.of(null); // NPE
Optioanl<String> optVal3 = Optional.ofNullable(null);
Optional<String> optVal4 = null; // null로 초기화
Optional<String> optVal5 = Optional.<String>empty(); // 빈 객체로 초기화
// Optional 객체의 값 가져오기
Optional<String optVal6 = Optional.get(); // 값이 null이면 예외 발생
Optional<String optVal7 = Optional.orElse(""); // 값이 null이면 "" 반환
Optional<String optVal7 = Optional.orElseThrow(NullPointerException::new); // 값이 null이면 NPE 반환
옵셔널은 값을 랩핑(Wrapping)과 언랩핑(Unwrapping) 과정을 거친 후 값이 null
일 경우 대체하는 함수를 호출하는 등의 오버헤드가 있어 성능이 저하될 수 있다는 점이다. 따라서 메서드의 반환 값이 null
이 나올 수 없을 때는 옵셔널을 사용하지 않는 것이 성능에 도움이 될 수 있다.
그동안 자바8 버전에서 새롭게 도입된 기능들을 팀원 간의 코드 스타일을 맞추다보니 사용하는 방법만 터득하고 프로젝트에 적용해왔다. 그러나 ‘왜’ 이 기술이 생겨났는지, 람다식과 메서드 참조의 연관성과 어떻게 활용하면 좋을 지 고민하지 못했다. 이번 게시글을 계기로 자바8 기능을 되짚어가며 맥락을 파악할 수 있었다.
보기 좋게 깔끔하게 정리되어 있어 참고 많이 했어요! 감사합니다:)