Lambda Expression

함수형 프로그래밍 기법을 지원하는 자바의 문법 요소

 

 

람다식이란?

메서드를 하나의 식으로 표현한 것으로, 코드를 간결하고 명확하게 표현할 수 있다는 장점이 있습니다.

JDK1.8 이후에 도입되었으며, 익명 객체이기 때문에 기존의 자바 문법 요소를 해치지 않는 프로그래밍 기법이

필요했습니다. 이에 따라 함수형 인터페이스가 만들어 졌습니다.

 

 

람다식의 기본 문법
//기존 메서드 표현 방식
void sayhello() {
	System.out.println("HELLO!")
}

//위의 코드를 람다식으로 표현한 식
() -> System.out.println("HELLO!")

기존의 메서드와 가장 두드러지게 나타나는 차이는

기본적으로 반환타입과 이름을 생략할 수 있다는 점입니다. 따라서 람다 함수를 익명 함수라고도 부릅니다.

 

 

람다식을 만드는 방법
// 기본 문법
int sum(int num1, int num2) {
	return num1 + num2;
}

// 람다식
(int num1, int num2) -> {
	num1+num2
}

1) 반환타입, 메서드명 제거 후 화살표 추가

2) return , 세미콜론 제거

 

// 기존 방식
void example1() {
	System.out.println(5);
}

// 람다식
() -> System.out.println(5);

: 실행문이 하나만 존재할 경우 중괄호 생략 가능

 

 

함수형 인터페이스

자바에서 함수는 반드시 클래스 안에서 정의되어야 하기 때문에 메서드가 독립적으로 있을 수 없습니다.

반드시 클래스 객체를 먼저 생성한 후 생성한 객체로 메서드를 호출해야합니다.

이러한 맥락에서 람다식 또한 사실은 객체입니다. 더 정확히는 이름이 없기 때문에 익명 클래스라 할 수 있습니다.

그래서 실제 우리가 사용하는 익명함수의 형태는 아래 코드와 같으며,

익명 클래스는 생성고 선언을 동시에 하는 단 한번만 사용된느 일회용 클래스입니다.

new Object() {
	int sum(int num1, int num2) {
		return num1 + num1;
	}
}

그런데 문제는 익명 객체를 Object 클래스에 담는다고 해도 sum 메서드를 사용할 방법이 없습니다.

이 같은 문제를 해결하기 위해 사용되는 것이 함수형 인터페이스입니다.

이 방법은 기존의 인터페이스 문법을 활용해 람다식을 다루는 것입니다.

아래의 예제 코드로 살펴볼 수 있습니다.

public class LamdaExample1 {
    public static void main(String[] args) {
		   /* Object obj = new Object() {
            int sum(int num1, int num2) {
                return num1 + num1;
            }
        };
			*/ 
		ExampleFunction exampleFunction = (num1, num2) -> num1 + num2
		System.out.println(exampleFunction.sum(10,15))
}

@FunctionalInterface // 컴파일러가 인터페이스가 바르게 정의되었는 지 확인할 수 있도록
interface ExampleFunction {
		public abstract int sum(int num1, int num2);
}

// 출력값
25

다시 순차적으로 설명해보겠습니다.

 

1. 함수형 인터페이스 작성

public interface MyFunctionalInterface {
    public void accept();
}

 

2. 인터페이스의 람다식 작성

MyFunctionalInterface example = () -> { ... };

람대식이 대입된 인터페이스의 참조 변수는 accept를 위와 같은 호출할 수 있습니다.

 

3. 람다식으로 호출

public class MyFunctionalInterfaceExample {
	public static void main(String[] args) throws Exception {
		MyFunctionalInterface example;
		example = () -> {
			String str = "첫 번째 메서드 호출!";
			System.out.println(str);
		};
		example.accept();

		example = () -> System.out.println("두 번째 메서드 호출!");
		//실행문이 하나라면 중괄호 { }는 생략 가능
		example.accept();
	}
}

// 출력값
첫 번째 메서드 호출!
두 번째 메서드 호출!

 

 

매개변수가 있는 람다식

1. 함수형 인터페이스에 매개변수가 존재

public interface MyFunctionalInterface {
    public void accept(int x);
}

 

2. 해당 인터페이스를 타겟 타입으로 갖는 람다식을 작성

public class MyFunctionalInterfaceExample {
    public static void main(String[] args) throws Exception {
        MyFunctionalInterface example;
        example = (x) -> {
            int result = x * 5;
            System.out.println(result);
        };
        example.accept(2);

        example = (x) -> System.out.println(x * 5);
        example.accept(2);
    }
}

// 출력값
10
10

 

 

 

return 문이 있는 람다식
public class MyFunctionalInterfaceExample {
    public static void main(String[] args) throws Exception {
        MyFunctionalInterface example;
        example = (x, y) -> {
            int result = x + y;
            return result;
        };
        int result1 = example.accept(2, 5);
        System.out.println(result1);
        

        example = (x, y) -> { return x + y; };
        int result2 = example.accept(2, 5);
        System.out.println(result2);
       

	      example = (x, y) ->  x + y;
				//return문 만 있을 경우, 중괄호 {}와 return문 생략가능
        int result3 = example.accept(2, 5);
        System.out.println(result3);
       

        example = (x, y) -> sum(x, y);
				//return문 만 있을 경우, 중괄호 {}와 return문 생략가능
        int result4 = example.accept(2, 5);
        System.out.println(result4);
 
    }

    public static int sum(int x, int y){
        return x + y;
    }
}

//출력값
7
7
7
7

여기서 주의 깊게 볼점은 return문만 있는 경우 중괄호를 생략한다는 점입니다.

 

 

메서드 레퍼런스

메서드 참조는 불필요한 매개변수를 제거할 때 주로 사용합니다.

람다식은 종종 기존 메서드를 단순히 호출만 하는 경우가 많습니다.

예를 들어, 아래 max 메서드를 호출하는 람다식에서 람다식의 역할은 단순히 매개 값 전달만 하여 불편해보입니다.

(left, right) -> Math.max(left, right);

그래서 이러한 경우 때문에 메서드 참조를 이용해 깔끔하게 처리할 수 있습니다.

Math :: max

 

 

메서드 레퍼런스의 정적 메서드와 인스턴스 메서드 참조
  1. 정적 메서드를 참조할 경우 클래스 이름 뒤에 :: 기호를 붙이고 정적 메서드 이름을 기술합니다.
  2. 인스턴스 메서드의 경우에는 먼저 객체를 생성한 다음 참조 변수 뒤에 ::기호를 붙이고 인스턴스 메서드 이름을 기술하면 됩니다.
public class Calculator {
  public static int staticMethod(int x, int y) {
                        return x + y;
  }

  public int instanceMethod(int x, int y) {
   return x * y;
  }
}

----------------------------------------------------------------

import java.util.function.IntBinaryOperator;

public class MethodReferences {
  public static void main(String[] args) throws Exception {
    IntBinaryOperator operator;

    /*정적 메서드
		클래스이름::메서드이름
		*/
    operator = Calculator::staticMethod;
    System.out.println("정적메서드 결과 : " + operator.applyAsInt(3, 5));

    /*인스턴스 메서드
		인스턴스명::메서드명
		*/
		
    Calculator calculator = new Calculator();
    operator = calculator::instanceMethod;
    System.out.println("인스턴스 메서드 결과 : "+ operator.applyAsInt(3, 5));
  }
}
/*
정적메서드 결과 : 8
인스턴스 메서드 결과 : 15
*/

 

메서드 레퍼런스의 생성자 참조

단순히 메서드 호출로 구성된 람다식은 메서드 참조로 대치할 수 있듯이,

단순히 객체를 생성하고 리턴하도록 구성된 람다식은 생성자 참조로 대치 가능합니다.

 

(a,b) -> {return new 클래스(a,b);};
// 이런 경우 아래로 바꾼다.
클래스 :: new

 

예제를 보겠습니다.

//Member.java
public class Member {
  private String name;
  private String id;

  public Member() {
    System.out.println("Member() 실행");
  }

  public Member(String id) {
    System.out.println("Member(String id) 실행");
    this.id = id;
  }

  public Member(String name, String id) {
    System.out.println("Member(String name, String id) 실행");
    this.id = id;
    this.name = name;
  }

  public String getName() {
    return name;
  }

public String getId() {
    return id;
  }
}
import java.util.function.BiFunction;
import java.util.function.Function;

public class ConstructorRef {
  public static void main(String[] args) throws Exception {
    Function<String, Member> function1 = Member::new;
    Member member1 = function1.apply("kimcoding");

    BiFunction<String, String, Member> function2 = Member::new;
    Member member2 = function2.apply("kimcoding", "김코딩");
  }
}

/*
Member(String id) 실행
Member(String name, String id) 실행
*/

 

스트림

배열, 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 해주는 반복자입니다.

 

  1. 스트림을 사용하면 List, Map, Set, 배열 등 다양한 데이터 소스로 부터 스트림을 만들 수 있고, 표준화된 방법으로 다룰 수 있습니다.
  2. 스트림은 데이터 소스를 다루는 풍부한 메서드를 제공합니다.
  3. 다량의 데이터에 복잡한 연산을 수행하면서도 가독성, 재사용성이 높은 코드를 작성할 수 있습니다.

 

선언형 프로그래밍

어떻게 수행하는지보다는 무엇을 수행하는지에 관심을 두는 프로그래밍 패러다임입니다.

명령형 방식은 하나하나 절차를 따라가야 코드를 이해할 수 있지만,

선언형 방식은 코드를 작성하면 내부 동작 원리를 모르더라도 코드가 무슨일을 하는지 이해할 수 있습니다.

즉, "어떻게" 영역은 추상화되있습니다.

 

// 기존의 리스트 조회 방식
public class ImperativeProgrammingExample {
    public static void main(String[] args){
        // List에 있는 숫자들 중에서 4보다 큰 짝수의 합계 구하기
        List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);
        int sum = 0;

        for(int number : numbers){
            if(number > 4 && (number % 2 == 0)){
                sum += number;
            }
        }

        System.out.println("# 명령형 프로그래밍 : " + sum);
    }
}


// 스트림 방식
public class DeclarativeProgramingExample {
    public static void main(String[] args){
        // List에 있는 숫자들 중에서 4보다 큰 짝수의 합계 구하기
        List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);

        int sum =
                numbers.stream()
                        .filter(number -> number > 4 && (number % 2 == 0))
                        .mapToInt(number -> number)
                        .sum();

        System.out.println("# 선언형 프로그래밍: " + sum);
    }
}

 

 

람다식, 매서드 참조를 이용해 요소 처리
//Student.java
public class Student {
    private String name;
    private int score;

    public Student(String name, int score){
        this.name = name;
        this.score = score;
    }

    public String getName(){
        return name;
    }

    public int getScore(){
        return score;
    }
}

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class StreamLambdaExample {
    public static void main(String[] args) throws Exception {
        List<Student> list = Arrays.asList(
            new Student("김코딩", 95),
            new Student("이자바", 92)
        );

        Stream<Student> stream = list.stream();
        stream.forEach( s -> {
            String name = s.getName();
            int score = s.getScore();
            System.out.println(name+ " - " +score);
        });
    }
}
/*
김코딩 - 95
이자바 - 92
*/

1) list를 stream형으로 바꿔서 stream 객체에 할당

2) stream 객체를 람다식으로 순환

 

 

내부 반복자를 사용하므로 병렬 처리가 쉽다는 장점이 있습니다.
  • 외부 반복자 : 개발자가 코드로 직접 컬렉션 요소를 반복해서 가져오는 것으로, [ for, Iterator, while ] 등이 해당됩니다.
  • 내부 반복자 : 컬렉션 내부에서 요소들을 반복시키고 개발자는 요소당 처리해야할 코드만 제공하는 코드 패턴입니다.

 - 내부 반복자의 장점

 

  1. 내부 반복자를 사용하게되면 컬렉션 내부에서 어떻게 반복시킬지는 컬렉션에 맡겨두고, 개발자는 요소 처리코드에만 집중할 수 있다는 점이 있습니다.
  2. 내부 반복자는 멀티 코어 CPU를 최대한 활용하기 때문에 병렬 작업을 통해 효율적으로 요소를 반복할 수 있습니다
  3. 중간 연산과 최종 연산을 할 수 있습니다.

* 병렬 처리

: 한 가지 작업을 서브 작업으로 나누고, 서브 작업들을 분리된 스레드에서 병렬처리하는 것을 의미합니다.

 병렬 스트림을 사용하기 위해선 스트림의 parallel() 메서드를 사용해야 합니다.

 

 * 중간연산과 최종연산 예시

: 학생 객체를 요소로 가지는 컬렉션에서 중간 연산에서는 학생의 점수를 뽑아내고, 최종 연산에는 점수의 평균값을 산출할 수 있습니다.

 

 

리덕션

데이터를 가공해서 축소하는 것을 의미합니다.

평균값, 카운팅, 최대/최소 값 등이 대표적인 예시입니다.

그러나 컬렉션의 요소를 바로 집계할 수 없을 때는 filter, mapping, sort 등 중간 연산이 필요합니다.

 

 

파이프라인

스트림은 필터링,매핑,그루핑,정렬 등의 중간 연산과 합계, 평균, 카운팅, 최대/최소 값 등의 최종 연산을

파이프라인으로 해결합니다.

 

파이프 라인은 여러 스트림이 연결되있는 구조입니다.

파이프 라인에선 최종 연산을 제외하곤 모두 중간 연산입니다.

 

중간 스트림이 생성될 때 중간 연산이 되는게 아니라 최종 연산이 시작되기 전까지 지연됩니다.

최종 연산이 시작되면 비로소 컬렉션 요소가 하나씩 중간 스트림에서 연산되고 최종 연산에 오게 됩니다.

 

예제로 살펴보겠습니다.

Stream<Member> maleFemaleStream = list.stream();
Stream<Member> maleStream = maleFemaleSTream.filter(m -> m.getGender() == Member.MALE);
IntStream ageStream = maleStream.mapToInt(Member::getAge);
OptionalDouble opd = ageStream.average();
double ageAve = opd.getAsDouble();

.filter(m-> m.getGender() == Member.MALE) 는 남자 Member 객체를 요소로 하는 새로운 스트림을 생성합니다.

.mapToInt(Member::getAge) 는 Member 객체를 age 값으로 매핑해서 age를 요소로 하는 새로운 스트림을 생성합니다.

average() 메소드는 age 요소의 평균을 OptionalDouble에 저장합니다. OptionalDouble에 저장된 평균 값을 읽으려면 getAsDouble() 메소드를 호출 하면 됩니다(Optional은 이후에 자세히 다룹니다).

 

위 코드에서 로컬 변수를 생략하고 연결하면 다음과 같은 형태의 파이프라인 코드만 남습니다.

 

스트림의 주의할 점
  1. 스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐 변경하지 않습니다.
  2. 스트림은 일회용입니다. 한번 사용하면 닫히므로 새로운 스트림을 열어야합니다.

* 단, 중간 스트림은 하나의 스트림에 여러번 사용할 수 있습니다.

 

 

스트림 생성
// List로부터 스트림을 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> listStream = list.stream();
listStream.forEach(System.out::prinln); //스트림의 모든 요소를 출력.

 

 

중간 연산
  • distinct() : stream의 요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 사용합니다.
  • filter() : stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어냅니다.
  • map() : 기존의 stream 요소들을 대체하는 요소로 구성된 새로운 스트림을 형성하는 연산                                                    mapToInt(), mapToLong(), mapToDouble() 등이 있음
  • sorted() : Comparator 인자 없이 호출할 경우 오름차순으로 정렬되며, 내림차순 정렬은 reverseOrder 이용합니다.
  • peek() : 요소를 하나씩 돌면서 출력 

 

 

최종 연산

연산 결과가 스트림이 아니므로 한번만 연산이 가능합니다.

  • forEach() : 파이프라인 마지막에서 요소를 하나씩 연산
  • match() : 특정한 조건을 충족하는지 검사할 때 사용 [ allMatch, anyMatch, noneMatch ]
  • sum,count,average,max,min() : 기본 집계
  • reduce() : 하나의 응축으로 하는 방식입니다. count, sum 등 집계 메서드는 내부적으로 reduce가 있습니다.
  • collect() : List,Set,Map 등 다른 종류의 결과로 수집하고 싶은 경우 이용합니다.

 

'JAVA' 카테고리의 다른 글

HashMap  (0) 2022.08.10
백트래킹  (0) 2022.07.27
좋은 객체 지향 설계의 5가지 원칙  (0) 2022.07.08
객체 지향 프로그래밍  (0) 2022.07.08
String 중간 공백기준으로 배열 만들기  (0) 2022.07.06

+ Recent posts