[JAVA] 람다 & 스트림 (feat. Optional API)

FE.1·2024년 4월 10일
8
post-thumbnail

람다(Lamda)


자바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;
	}
}

스트림(Stream)


그동안 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 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는 반드시 한 번의 종단조작 메서드를 불러, 최종적인 결과를 얻는다.

  • forEach
  • allMatch, anyMatch, noneMatch
  • count
  • max, min
  • sum, average
  • reduce
  • collect
// 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번 이상의 중간조작에 의해 데이터의 변환이나 추출 등을 수행할 수 있다.

  • limt
  • filter
  • map
  • sorted
// 첫 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()));

Optional<T>

  • 스트림 최종 연산의 결과 타입이 Optional인 경우가 있다.

  • Optional<T>는 제네릭 클래스로 T타입의 객체를 감싸는 래퍼 클래스이다.

  • NPE를 편하게 제어하기 위한 자바8 기능이다.

    • 이전에는 조건문을 통해 null 체크를 해야하는 번거로움이 있었다.

Optional API

Stream처럼 중간 연산 및 종단 연산을 수행할 수 있다.

생성 메서드

  • of
  • ofNullable
    • 참조변수의 값이 null일수도 있을 경우 예외(NPE)를 방지하기위해 of 대신 사용한다.

중간 연산 메서드

  • filter
  • map
  • flatMap

종단 연산 메서드

  • ifPresent
  • get
    • 값을 가져올 때 사용하는데, null인 경우 NoSuchElementException 발생한다.
  • orElse
  • orElseGet
  • orElseThrow
// 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 기능을 되짚어가며 맥락을 파악할 수 있었다.


참고1
참고2 Java의 정석

profile
공부하자!

2개의 댓글

comment-user-thumbnail
2024년 4월 10일

보기 좋게 깔끔하게 정리되어 있어 참고 많이 했어요! 감사합니다:)

1개의 답글