Java 확장 문법 - 함수형 인터페이스 : Monad 개념과 Optional, Stream

2SEONGA·2025년 2월 4일
0

Java

목록 보기
8/13
post-thumbnail

1. Java 8 Functional Interface 함수형 인터페이스

(1) 익명 클래스와 익명 구현 객체

익명이란? = 일회용

  • 익명 함수, 익명 클래스 등 “익명”은 여러번 사용하기 위해 이름을 붙인 “기명”과 달리, 일회용의 개념
    • 한 번만 쓰고 버릴 것 = 일회용 종이컵과 같은 것
    • 한 번만 쓰고 버릴 것이니, 메모리에 상주시키거나 따로 관리하기 싫은 경우 사용
  • 익명 함수의 경우 수학식의 람다(Lambda)로 표현

일회용 클래스와 일회용 함수

  • 자바에도 일회용의 개념이 존재
    • 다만, 자바에는 함수라는 개념이 존재하지 않고 메서드(클래스 내)라는 개념만 존재
    • Java 8 버전 이전부터는 익명 클래스(일회용 클래스)만 존재
    • Java 8 버전 이후부터는 함수형 프로그래밍이 도입됨에 따라 익명 함수를 위한 익명 클래스가 존재
      • 객체지향 프로그래밍 Java 에 함수형 프로그래밍 패러다임 도입을 위해 람다 함수 추가
  • 일회용 클래스와 일회용 함수 지원을 위해 Java 는 아래의 문법 제공
    • 익명 (상속) 클래스 : 클래스를 상속(extends)한 익명 클래스 = 동시에 객체 생성
    • 익명 구현 객체 : 인터페이스를 구현(implements)한 익명 클래스 = 동시에 객체 생성

익명 (상속) 클래스 : 클래스를 상속한(extends) 익명 클래스

(부모) 동물 클래스를 상속받은 (자식) 개 클래스를 만들어서 사용하고 싶다면, 기명 클래스익명 클래스 방법 존재

방법 1) 기명 클래스를 정의한 다음 → 인스턴스화 = 여러번 사용 가능

class Animal {
    public String cry() {
        return "(울음)";
    }
}
class Dog extends Animal {
    public String cry() {
        return "멍멍";
    }
}
Animal schnauzer = new Dog();
Animal poodle    = new Dog();
Animal maltese   = new Dog();
  • Dog (기명) 클래스 : 여러번 사용 가능

방법 2) 익명 클래스를 정의와 동시에 + 인스턴스화 = 한 번 사용되고 더 이상 미사용

Animal chihuahua = new Animal() {
    public String cry() {
        return "왈왈";
    }
}
  • 익명 클래스이기에 한 번만 사용할 때 사용 = 여러번 사용 불가능
  • 단점 : 무조건 부모 클래스가 존재해야한다는 점

익명 구현 객체 : 인터페이스를 구현한(implements) 익명 클래스

(부모) 동물 인터페이스를 구현한 개자식 클래스를 만들어 사용하고 싶다면, 기명 클래스익명 클래스 방법 존재

방법 1) 구현 클래스를 정의한 다음 → 인스턴스화 = 여러번 사용 가능

interface IAnimal {
    public String cry();
}
class Dog implements IAnimal {
    public String cry() {
        return "멍멍";
    }
}
IAnimal schnauzer = new Dog();
IAnimal poodle    = new Dog();
IAnimal maltese   = new Dog();
  • Dog (기명) 클래스 : 여러번 사용 가능

방법 2) 구현 클래스를 정의와 동시에 + 인스턴스화 = 한번 사용되고 더 이상 미사용

IAnimal chihuahua = new IAnimal() {
    public String cry() {
        return "왈왈";
    }
}
  • 익명 구현 클래스이기에 한번만 사용할 때 사용 = 여러번 사용 불가능
  • 장점 : 클래스 없이 바로 인터페이스를 통해서만 구현한다는 점 → 익명 함수 구현체로 적합

(2) 익명 구현 객체를 통한 익명 함수 정의

왜 Java 는 익명 구현 객체 문법을 만들었을까?

  • 함수형 프로그래밍 = 일급함수 + 순수함수
    • 일급함수 = 함수가 변수, 파라미터, 반환으로 사용될 수 있다.
    • 순수함수 = No Side-Effects 참조 투명성 = 반복 호출해도 언제나 같은 결과(or 상태)
  • Java 는 함수가 아닌 메서드
    즉, 우리가 말하는 함수는 메소드로써 꼭 객체 내 정의
    - 그래서, Java 에서 익명 함수를 정의하기 위해서는 익명 구현 객체가 꼭 필요

인터페이스를 통한 익명 구현 객체 안에 익명 함수를 정의하여 사용

  • 예시) 만약에 members 라는 Array 가 있고, 그 Array 를 특정 값 기준으로 나열하고싶다면
Member[] members = {
        new Member(12, "Aaron"),
        new Member(11, "Baron"),
        new Member(13, "Caron"),
        new Member(10, "Daron"),
};
  • Member 들 중 나이값을 기준으로 오름차순으로 나열하고싶다면
    • 어떤걸 기준으로 나열할지에 대한 함수 정의(익명 함수)를 Arrays.sort 함수의 Parameter 로 넘겨야
      • 파라미터 1 : 어떤거를 = 배열 파라미터
      • 파라미터 2 : 어떻게 = 함수 파라미터 (익명 함수)
Arrays.sort(members, 익명 함수 = 어떤걸 기준으로 나열할지)
  • 위에서 했듯 Java 에서 익명 함수를 쓰기 위해서는 익명 구현 객체 사용
Arrays.sort(members, new Comparator<Member>() {
    @Override
    public int compare(Member m1, Member m2) {
        return Integer.compare(m1.getId(), m2.getId());
    }
})
  • 아래 익명 함수의 형태(화살표 함수 형태)를 () -> {} Lambda라 호칭
Arrays.sort(members, (Member m1, Member m2) -> { return Integer.compare(m1.getId(), m2.getId()); });
Arrays.sort(members, (Member m1, Member m2) ->          Integer.compare(m1.getId(), m2.getId())   );
Arrays.sort(members, Comparator.comparingInt(each -> each.getId()));
Arrays.sort(members, Comparator.comparingInt(Member::getId));

익명 함수는 어떤 형태들이 될 수 있나?

  • 익명 구현 객체는 우리가 어떤 형태의 익명 함수를 쓸지에 대해 종류들을 인터페이스로 나눠놓은 것
    • 파라미터가 없고, 결과값이 있는 경우 : ( ) ⇒ (R) = Supplier 라는 인터페이스를 만들어둠
    • 파라미터가 있고, 결과값이 없는 경우 : (T) ⇒ ( ) = Consumer 라는 인터페이스를 만들어둠

Functional Interface 함수형 인터페이스 : 익명 함수 형태들 미리 정의

익명 함수가 될 수 있는 형태들을 미리 정의하여 제공 = 익명 구현 객체의 Syntatic Sugar

  • 파라미터 0~1개
함수명함수 형태 (파라미터, 반환)익명 함수명
Function(T) → (R)apply
Predicate(T) → (BOOLEAN)test
UnaryOperator(T) → (T)apply
Supplier() → (R)get
Consumer(T) → ()accept
  • 파라미터 2개
함수명함수 형태 (파라미터, 반환)익명 함수명
BiFunction(T, U) → (R)apply
BiPredicate(T, U) → (BOOLEAN)test
BiUnaryOperator(T, T) → (T)apply
파라미터가 없어서 제외
BiConsumer(T, U) → ()accept

(1) 함수형 프로그래밍 | 일급함수 First-class Function 위해 함수형 인터페이스 활용

  1. 함수를 변수에 할당 가능
Function<Integer, Integer> multiplier = (Integer integer) -> integer * 10;
  1. 함수를 파라미터에 할당 가능
public static int multiply(int operand, Function<Integer, Integer> multiplier) {
    return multiplier.apply(operand);
}
Function<Integer, Integer> multiplier = (Integer integer) -> integer * 10;
Integer calculated = multiply(3, multiplier)
System.out.println(calculated);
  • 바로 위에서 정의한 1. 함수 변수를 활용하여 2. 함수 파라미터로 바로 넘겨서 호출 가능
  1. 함수를 반환값으로 반환 가능
public static Function<Integer, Integer> create(int operand) {
    return (Integer integer) -> integer * operand;
}
Function<Integer, Integer> multiplier = createMultiplier(10);
System.out.println(multiply(3, multiplier));
System.out.println(calculated);
  • 3. createMultiplier 함수를 통해 만들어 반환한 함수를 바로 위에서 정의한 2. 함수 파라미터로 넘겨 실행

2. Java 에서의 Monad : Optional 그리고 Stream

(1) 함수형 프로그래밍에서 Functor 와 Monad 개념

Functor

“Mapping 함수 (값 → 값) 적용 방식”을 갖는 “데이터 구조”
ex) List

즉, 값의 데이터 구조에서 → 새 값의 데이터 구조가 나오는것


Monad

“Mapping 함수 (값 → 값 with 상태) 적용 방식”을 갖는 “데이터 구조”
ex) Optional

즉, 값의 데이터구조에서 → 오류 상태를 포함한 값의 데이터구조가 나오는 것

  • Functor 는 Mapping 함수를 통해 단순히 값에서 값으로의 전환 발생
  • Monad 는 Mapping 함수를 통해 단순히 값에서 상태를 포함한 값으로의 전환 발생
    • No Side Effect 의 순수함수성을 위해 성공(값)과 실패(NULL)라는 상태를 가진 값을 반환
  • Monad 의 장점
    • NPE 방지 : 실패 상태를 가진 “값”이기에 어떤 함수에서도 문제를 발생시키지 않는다.
    • 지연 연산 (Lazy Evaluation) : 중간에 Exception 이 발생해도, 실패 상태를 갖고 이어져 나중에 처리

  • 상태를 갖는 값으로 반환하기 위해 Mapping 가 “값 → 상태가진 값” 형태를 갖는데,
    • 이것이 Function Composition (Chaining) 이 불가능하도록 막음
    • Function Composition (Chaining) 지원을 위해 flatMap 으로 한꺼풀 벗겨내야함


(2) Java 8 버전에서 Optional

Optional 이 등장한 이유 : Null 을 외부가 아닌 내부에서 처리하자

왜 Optional 이 등장하였는가? (일반적으로) 발생한 Null 을 외부적으로 처리하는것에 대한 불편함 해결

  • 굴뚝 (초등학교때 배운 함수 그림)
    • 굴뚝 밖에서 Null 처리 : if 문을 통해 (외부에서) 연결
    • 굴뚝 안에서 Null 처리 : Optional 객체를 통해 (내부에서) 연결

[Optional 이 없던 시절, Java에서 Null 을 처리하는 법]

/* 주문 */
public class Order {
	public Long id;
	public Date date;
	public Member member;
}

/* 회원 */
public class Member {
	public Long id;
	public String name;
	public Address address;
}

/* 주소 */
public class Address {
	public String street;
	public String city;
	public String zipcode;
}
if (order) {
		Member member = order.member;
		if (member) {
				Address address = member.address;
				if (address) {
						return address.city;
				} else {
						return "Seoul";
				}
		}
}
  • Optional 이 등장하기 전의 Java 에서는 if-else 문법을 통해 Null 처리

[JavaScript 에서 Null 을 처리하는 법]

order?.member?.address?.city ?? "Seoul"
  • JavaScript 에서는 Null Cascading(?.) 과 Nullish coalescing(??) 통해 Null 을 내부에서 처리
  • ?? : Default = 앞엣값이 Null 인 경우 표시할 값 (Nullish coalescing)
  • ?. : Null 이면 넘김
    Null Cascading 이라고도 불림

//                  order?.member                     ?.address                       ?.city                         ??       "Seoul"
Optional.ofNullable(order).map((order) -> order.member).map((member) -> member.address).map((address) -> address.city).orElse("Seoul")
  • 앞 Javascript 로직과 본 로직을 비교했을때, Optional 가 함수형 프로그래밍을 위해 나온것임을 이해 가능

Null 을 외부에서 처리했을때 어떤 문제가 있는가?
NPE(NullPointerException) 발생에 대한 처리도 외부에 위치
위에서 본 바와 같이 Null 체크 로직이 외부에 위치하기에 로직의 유지보수성과 가독성 저하


Optional : 상태(값이 없는 / 값이 있는)를 갖는 객체

위에서 살펴본 Null 을 외부에서 처리하는 단점 커버를 위해 아닌 내부에서 처리하기 위해서 Optional 등장

  • Optional 은 쉽게 얘기하자면 상태를 갖는 객체로써, 값이 없을 수도 있다 = 값이 없거나 / 있거나
    • 변수 선언 : Optional<Member> : Generic 제네릭을 통해 어떤 타입의 값을 가질 수 있는지 명시
    • 객체 생성 : Optional.ofNullable() : 값이 없을수도 있는 = 값이 없는 / 값이 있는 Optional
      • Optional.of() : 값이 있는 Optional | null 을 Optional.of() 안에 넣어 호출하면 오류 발생
      • Optional.empty() : 값이 없는 Optional
      • .of() .empty() .ofNullable() 모두 정적 팩토리 메서드에 해당
Optional<Member> optionalMember = Optional.of(member);          // = 성공(값이 있다) 상태 + Member 객체
Optional<Member> optionalMember = Optional.empty();             // = 실패(값이 없다) 상태 + Null 객체
Optional<Member> optionalMember = Optional.ofNullable(member);  // = 값이 있을수도, 없을수도 상태 + 객체
  • Optional 을 통해 Null 을 내부에서 처리했을 때의 장점은?
    • 외부에서 Null 을 직접 다루지않아도 가능
      • 특정 값이 Null 인지 여부 판단
      • 특정 값이 Null 인 경우 별개 처리
    • 명시적으로 Null 인지 여부를 코드로써 표기 가능
      Optional.ofNullable()

Optional 사용

Optional<Member> memberExist = Optional.ofNullable(member);
Optional<Member> memberNotExist = Optional.ofNullable(null);

참고 : DaleSeo 블로그

  • 값을 가진 Optional - memberExist = Optional.ofNullable(**member**)
    • Optional 에 값이 존재하는 경우,
      • `.get()` : Optional 에 존재하는 값을 반환
Member member = optionalMember.get();
  • 값이 없는 Optional - memberNotExist = Optional.ofNullable(**null**)
    • Optional 에 값이 존재하면 그 값을 가져오고,
    • Optional 에 값이 존재하지 않은 경우,
      • .orElse(T other) : 기본값을 가져온다 (Failover 값)
      • .orElse**Get**(Supplier<? extends T> other) : 기본값을 반환하는 함수 (Failover 반환)
      • .orElse**Throw**(Supplier<? extends X> exceptionSupplier) : Exception 발생 함수
Member member = optionalMember.orElse(new Member(0, "Empty"));
Member member = optionalMember.orElseGet(() -> { return new Member(0, "Empty"); });
Member member = optionalMember.orElseThrow(() -> { return new RuntimeException("유저 미존재"); });

Optional 을 정상적인 방법으로 사용하는 사례 : 사용 목적에 맞게 이렇게 개발

int length = Optional.ofNullable(getText()).map((string) -> string.length()).orElse(0);
int length = Optional.ofNullable(getText()).map(String::length).orElse(0);
  • (string) -> string.length() lamda 함수는 String::length 로 간단히 표현 가능

Optional 활용

  1. .map() : Optional 안에 있는 값을 바꾸기
/* 주문을 한 회원이 살고 있는 도시를 반환한다 */
public String getCityOfMemberFromOrder(Order order) {
		return Optional.ofNullable(order)
        .map((order) -> order.getMember())
        .map((member) -> member.getAddress())
        .map((address) -> address.getCity())
				.orElse("Seoul");
}
		return Optional.ofNullable(order)
				.map(Order::getMember)
				.map(Member::getAddress)
				.map(Address::getCity)
				.orElse("Seoul");
  • Java 는 위와 같이 간략하게 표현할 수 있는 문법을 제공
/* 만약 Optional 을 사용하지 않았다면 다음과 같았을것 */
public String getCityOfMemberFromOrder(Order order) {
		if (Objects.nonNull(order)) {
		    Member member = order.member;
		    if (Objects.nonNull(member)) {
		        Address address = member.address;
		        if (Objects.nonNull(address)) {
		            return address.city;
		        } else {
		            return "Seoul";
		        }
		    }
		}
}
  • .ofNullable() : 파라미터로 받은 Order 객체가 null 일수도 있기에 of() 대신 ofNullable() 사용
  • .map() : 연쇄 호출(Chaining)을 통해 Optional 객체를 3번 변환
    • Optional<Order> → Optional<Member> → Optional<Address> → Optional<String>
  • .orElse() : 3번이나 변환을 마친 결과 Optional 이 비어있을 경우 Failover 값으로 사용할 "Seoul"
    • Optional<String> Optional 이 비어있을 경우 = Optional 내 String 이 없을 때
  1. .filter() : Optional 안에 있는 값을 검사/분류하기
/* 특정 시간 이내 주문을 한 회원만 반환한다 */
public Optional<Member> getMemberIfOrderWithin(Order order, int min) {
		return Optional.ofNullable(order)
				.filter(o -> o.getDate().getTime() > System.currentTimeMillis() - min * 1000)
				.map(Order::getMember);
}

Optional 활용 예

참고 : DaleSeo 블로그

  1. Null 상황 처리
Map<Integer, String> cities = new HashMap<>();
cities.put(1, "Seoul");
cities.put(2, "Busan");
cities.put(3, "Daejeon");
  • Optional 을 사용하지 않은 경우 : null 이 외부에 존재할뿐 아니라 외부에서 처리
String city = cities.get(4);                                     // returns null
int length = city == null ? 0 : city.length();                   // null check
System.out.println("length: " + length);
  • Optional 을 사용하는 경우 : null 이 외부에 노출되지도 않고, 내부에서 처리
Optional<String> maybeCity = Optional.ofNullable(cities.get(4)); // Optional
int length = maybeCity.map(String::length).orElse(0);            // null-safe
System.out.println("length: " + length);

  1. 예외 처리
List<String> cities = Arrays.asList("Seoul", "Busan", "Daejeon");
  • Optional 을 사용하지 않은 경우 + Exception 예외와 메인 로직이 분리되지 않은 경우
String city = null;
try {
		city = cities.get(3); // throws exception
} catch (ArrayIndexOutOfBoundsException e) {
		// ignore
}
int length = city == null ? 0 : city.length(); // null check
System.out.println(length);
  • Optional 을 사용하는 경우 + Exception 예외와 메인 로직을 분리한 경우
    • 좋은 예시이자 좋은 코드 그 이유는 Failover 처리를 해주고 있기 때문
public static <T> Optional<T> getAsOptional(List<T> list, int index) {
		try {
				return Optional.of(list.get(index));
		} catch (ArrayIndexOutOfBoundsException e) {
				return Optional.empty();
		}
}
Optional<String> maybeCity = getAsOptional(cities, 3);           // Optional
int length = maybeCity.map(String::length).orElse(0);            // null-safe
System.out.println("length: " + length);

  1. .ifPresent() : Optional 안에 있는 값이 존재할 경우 함수 실행
  • ifPresent(Consumer<? super T> consumer) : 정확히는 Consumer 함수형 인자를 받는걸 볼 수 있음
    • .isPresent() : Optional 안에 있는 값이 존재하는지 여부만 판단
Optional<String> maybeCity = getAsOptional(cities, 3); // Optional
maybeCity.ifPresent(city -> {
		System.out.println("length: " + city.length());
});

(3) Java 8 버전에서 Stream

Stream 은 무엇인가?

🛠 실습 : List<**Integer**> 에 각 요소에 대해 10을 곱한 뒤 문자열로 변환하는 로직을 Stream 으로 구현

List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
List<String> strings = integers
        /* List<Integer>   → Stream<Integer> */.stream()
        /* Stream<Integer> → Stream<String>  */.map((integer) -> String.valueOf(integer * 10))
        /* Stream<String>  → List<String>    */.toList();
  1. List<**Integer**> : 시작
  2. Stream<**Integer**> : 중간 연산을 위한 Stream
  3. Stream<**String**> : 연산을 모두 마친 Stream
  4. List<**String**> : 끝

Stream 이 등장한 이유 : Collection 각각의 요소에 함수형 프로그래밍을 도입하자

왜 Stream 이 등장하였는가? Collection 각각 요소에 대한 연산을 위해 for, while 로 외부에서 처리

  • Collection 밖에서 Element 요소 처리 : Loop 통해 외부에서 처리 (외부 반복 External Iteration)
  • Collection 안에서 Element 요소 처리 : Stream 통해 내부에서 처리 (내부 반복 Internal Iteration)

[Stream 이 없던 시절 Java 에서 Collection 내 Element 요소 처리하는 법]

for (String string : list) {
    if(string.contains("a")) {
        return true;
    }
}
  • Stream 이 등장하기 전의 Java 에서는 for 문법을 통해 이터레이션
boolean isExist = list.stream().anyMatch(element -> element.contains("a"));
  • Stream 이 등장한 후에는 간단하게 함수형 프로그래밍(함수형 인자)으로 내부 이터레이션

Stream : Collection 각각 요소에 대한 함수형 프로그래밍 처리 단위

  • Stream 은 쉽게 얘기하자면 Collection 처리를 위한 객체
    • 변수 선언 : Stream<String> : Generic 제네릭을 통해 어떤 타입의 요소를 가질 수 있는지 명시
    • 객체 생성 : Array 로부터 Stream 생성 / List 로부터 Stream 생성 / Stream 직접 생성
  1. 객체 생성 - Array 로부터 Stream 생성
String[] arr = new String[]{"a","b","c"};
Stream<String> stream = Arrays.stream(arr);
  1. 객체 생성 - List 로부터 Stream 생성 ← 현업에서 가장 많이 사용
List<String> lists = Arrays.asList("a", "b", "c");
Stream<String> stream = lists.stream();
  1. 객체 생성 - Stream 직접 생성
Strean<String> stream = Stream.of("a", "b", "c");

  • 함수형 프로그래밍 언어에서는 일급함수 특성에 따라 함수를 파라미터로(함수형 인자) 활용
    • 이에 따라 Stream 을 통한 처리의 장점 :
      • 선언형 : 더 간결하고 가독성이 좋아짐
      • 조립할 수 있음 : 유연성이 좋아짐
      • 병렬화 : 성능이 좋아짐

Stream 사용

  • Intermediate Operations : 연쇄 호출(Chaining) = 정확히는 Stream 반환

    • Mapping - .map : Stream 내 각각의 Element 요소를 다른 값으로 변환 (다른 타입으로도 가능)
    • Filtering - .filter : Stream 내 각각의 Element 요소에 대해 검사/분류
  • Terminal Operations : 값 반환
    - Matching - 조건에 따른 Boolean 값 반환
    - .anyMatch
    - .allMatch

        - **`.noneMatch`** 
    - Reduction - Stream 내 다수 요소로부터 단일 값 추출 및 반환 **`.reduce`** :
    - Collecting - Stream 내 다수 요소를 Collection 에 담아 반환 **`.collect`** 
    - **`.max`** 
    - **`.min`** 
profile
(와.. 정말 Chill하다..)

0개의 댓글