람다 표현식

Muzi·2023년 3월 23일
0

JAVA

목록 보기
4/4

함수형 프로그래밍

함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다. - 위키백과

함수형 프로래밍은 선언적 프로그래밍의 일종, 람다를 지원하기 전 Java는 완전한 명령형 프로그래밍

  • 명령형 프로그래밍 : 클래스에서 메서드를 정의하고, 필요시 메서드를 호출하여 동작
  • 선언적 프로그래밍 : 필요한 것이 어떤 것인지 기술하는 데 방점을 두고 애플리케이션의 구조를 세워 나가는 프로그래밍

함수형 프로그래밍의 조건

1. 불변성

: 상태를 변경하지 않는 것
자세한건 여기를

  • 무분별한 상태의 변경을 막을 수 있다(전역 변수의 남용 방지)
  • 데이터를 변경할 때 발생하는 문제 방지
  • 코드가 더욱 예측 가능(상태의 변경을 추적)하고 안정적, 더 쉽게 이해하고 유지보수 가능

2. 순수 함수

: 같은 입력시 같은 출력을 보장한다. 부수 효과(Side Effect)가 없다

Side Effect란?

함수에 예상할 수 없는 일이 생길 가능성이 존재하는 경우를 말한다
반환 값 이외에, 함수 외부의 상태에 영향을 미치는 것

// doSomething 메서드는 state 값에 영향을 받는다 = side effect 존재
public class SideEffectClass {
    private int state = 0;

    public void doSomething(int arg0) {
        state += arg0;
    }
}

3. 고차 함수

: 함수를 인자로 받거나 반환하는 함수

  • 1급 함수의 특징을 만족해야한다
    • 함수의 인자로 함수를 전달할 수 있다
    • 함수의 리턴값으로 함수를 사용할 수 있다
    • 인자로 함수를 전달할 수 있다
// 고차함수 map은 배열의 각 요소에 대해 주어진 함수를 실행함
// 이와 같은 고차함수는 코드의 재사용을 높이고(함수를 값으로 전달할 수 있어서) 간결한 코드를 만든다.
List<Integer> numbers = Arrays.asList(1, 2, 3);
List<Integer> doubledNumbers = numbers.stream().map(x -> x * 2).collect(Collectors.toList());
        System.out.println(doubledNumbers); // [2, 4, 6]

4. 지연 연산(Lazy Evaluation)

: 표현식의 평가를 그 값이 필요할 때까지 지연시키고 반복된 평가를 피하는 전략

장점

  • 불필요한 연산을 피할 수 있다
import java.util.stream.Stream;

public class LazyEvaluationExample {
    public static void main(String[] args) {
        Stream<Integer> infiniteStream = Stream.iterate(0, i -> i + 1);

        // 지연 연산
        // 실제로 이 연산은 최종 연산인 limit과 forEach가 호출될 때까지 지연된다
        Stream<Integer> evenNumbersStream = infiniteStream.filter(i -> i % 2 == 0);

        // 최종 연산
        // 10개의 짝수만 필요하므로 나머지 모든 요소에 대한 필터링 연산이 필요가 없다
        evenNumbersStream.limit(10).forEach(System.out::println);
    }
}

단점

  • 예외 발생시 파악하기 어렵다 - 디버깅 어려움
  • 상태를 가진 중간 연산시(peek, sorted, distinct 같은) 예기치 않은 결과 발생 가능
public class LazyEvaluation {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
		Stream<Integer> stream = numbers.stream()
          								.peek(n -> System.out.println("Peek " + n))
        								.filter(n ->  n % 2 == 0);
		System.out.println("Stream created.");
		stream.forEach(n -> System.out.println("Found " + n));
    }
}

/*
filter에 걸러진 요소만 출력되지 않는다. (예기치 않은 결과)
Peek 1
Peek 2
Found 2
Peek 3
Peek 4
Found 4
Peek 5
Peek 6
Found 6
Peek 7
Peek 8
Found 8
Peek 9
Peek 10
Found 10
*/

람다 표현식(Lambda Expressions)

: 식별자 없이 실행가능한 함수
람다식의 도입으로 자바는 객체지향언어인 동시에 함수형 언어가 되었다

익명 함수란?

함수의 이름이 없는 함수
1급함수의 특징을 가진다.

  • 메소드를 하나의 식으로 표현하는 것
  • 람다식으로 표현시 반환값 없어지므로 익명함수(anonymous function)라고도 함

그러면 Java는 완전한 함수형 언어가 된 것인가?

NO.
Java는 여전히 객체 지향 언어로서 상태와 가변 데이터를 다루는게 가능하다.(불변x)
따라서 Java는 객체 지향 언어와 함수형 언어의 특징을 모두 가진 멀티패러다임 언어

1. 장단점

장점

  • 간결한 코드 : 불필요한 반복문의 삭제가 가능하며, 복잡한 식을 단순하게 표현가능
  • 생산성 증가 : 메서드를 만드는 과정 생략 + 필요시점에 익명 객체를 구현하여 사용하기 때문에
  • 병렬처리 가능 : 멀티 쓰레드를 활용하여 병렬처리하기에 용이
  • 지연연산 수행 : 불필요한 연산 최소화
  • 가독성 증가

Q. 메서드와 함수의 차이

전통적으로 프로그래밍에서 함수라는 이름은 수학에서 따온 것
그러나 객체지향개념에서는 함수(function) 대신 객체의 행위나 동작을 의미하는 메서드(method) 라는 용어를 사용하는데

메서드는 함수와 같은 의미이지만, 특정 클래스에 반드시 속해야 한다는 제약이 있기 때문에 기존의 함수와 같은 의미를 다른 용어를 선택해서 사용했다.

그러나 람다식을 통해 메서드가 하나의 독립적인 기능을 하기 때문에 함수라는 용어를 사용하게 되었다.

단점

  • 람다식의 사용이 까다롭다
  • 까다로운 디버깅
  • 무분별한 람다의 사용은 가독성을 해침
  • 람다 stream은 단순 루프문보다 성능이 떨어진다
  • 재귀호출에 부적합하다

2. 람다식 사용법

람다는
1. 매개변수
2. 화살표 '->'
3. 함수몸체

세가지를 이용하여 사용한다

// before
int max(int a, int b) {
	return a > b ? a : b;
}
// lambda
(int a, int b) -> { return a > b ? a : b; }
  • 함수몸체가 단일 실행문일 경우 {}를 생략 가능
  • 함수몸체가 return문으로만 이뤄진경우 return은 생략하고 식(expression)으로 대신 가능
    - 문장이 아닌 식으로 ';' 생략
    (int a, int b) -> a > b ? a : b

  • 매개변수의 타입은 추론이 가능한 경우 생략가능
  • 반환타입이 없는 이유도 항상 추론이 가능하기 때문
    (a, b) -> a > b ? a : b

  • 매개변수와 return값이 없는 경우
    Runnable runnable = () -> { };

  • 매개변수가 1개인 경우 ()를 생략가능
  • 매개변수의 타입이 있으면 ()생략 불가
// 매개변수 1개
a -> a * a // OK
// 매개변수 2개 이상
(a, b) -> a * b // OK
// 매개변수 타입이 있는 경우 () 생략불가
int a -> a * a // Error

함수형 인터페이스 (Functional Interface)

: 추상 메소드만을 가진 인터페이스

  • 추상 메서드가 단 하나여야만 한다
  • 추상 메서드는 하나지만, static, default 메서드들은 가질 수 있다
    • @FunctionalInterface두 개 이상의 추상 메서드 선언이 안되도록 컴파일 체킹
    • 자세한건 어노테이션에서 다룬다

추상 메서드가 하나여야만 하는 이유?

Java의 람다식은 함수형 인터페이스에서만 사용가능한데, 예를들어 함수형 인터페이스에 추상 메서드가 두 개 이상 있으면 람다 표현식으로 어떤 메서드를 구현해야 하는지 불분명해지고 람다 표현식을 만들고 사용하는 구문이 더 복잡해진다

1. 익명 내부 클래스와 람다

아래 코드처럼 직접 함수형 인터페이스를 정의하고 람다식을 이용해 필요에 따라 구현 가능

@FunctionalInterface
interface MyFunction {
    int plus(int x, int y);
}
  • 해당 인터페이스를 익명 클래스 객체로 구현한다면?
    • 코드가 길지만 여러 추상메서드를 포함하는 인터페이스를 구현시에는 익명 내부 클래스가 필요
@FunctionalInterface
interface MyFunction {
    int plus(int x, int y);
}

public class AnonymousClassExample {
    public static void main(String[] args) {
        MyFunction myFunction = new MyFunction() {
            @Override
            public int plus(int x, int y) {
                return x + y;
            }
        };
        int result = myFunction.plus(3, 4);
        System.out.println(result); // 7
    }
}
  • 람다식을 통한 구현
// 간결해진다
public class LambdaExample {
    public static void main(String[] args) {
        MyFunction myFunction = (x, y) -> x + y;
        int result = myFunction.plus(3, 4);
        System.out.println(result); // 7
    }
} 

둘의 차이

1. 익명 내부 클래스는 새로운 클래스를 생성하지만, 람다는 새로운 메서드를 생성하여 포함
2. this

  • 익명 내부 클래스의 this : 새로 생성된 클래스
  • 람다의 this : 람다식을 포함하는 클래스
    • 클래스를 새로 만드는게 아닌 메서드를 만드는 람다는, 메서드 즉 람다가 있는 클래스를 가리킴

2. 기본 함수형 인터페이스

자세한건

namevalue설명
Runnable매개 값 X, 리턴 값 X쓰레드
Consumer매개 값 O, 리턴 값 X입력값은 존재하는데 내주는게 없다
Supplier매개 값 X, 리턴 값 O입력값은 존재하지않는데, 원하는게 미리 준비됨
Function매개 값 O, 리턴 값 O - 주로 매개 값을 리턴 값으로 매핑(타입변환)값을 변환
Operator매개 값 O, 리턴 값 X - 주로 매개값을 연산하고 결과를 리턴연산
Predicate매개 값 O, 리턴 값 X - 매개 값을 검사하여 boolean 리턴참/거짓 판단

3. 람다식은 어떻게 타입을 추론하는걸까?

: 타겟 타이핑(Target Typing)
자세한건 여기

Java 컴파일러는 람다식의 매개변수 타입을 추론하는 과정에서 다음과 같은 단계를 거친다

// sort 메서드 
default void sort(Comparator<? super E> c) {
	Object[] a = this.toArray();
	Arrays.sort(a, (Comparator) c);
	ListIterator<E> i = this.listIterator();
	for (Object e : a) {
		i.next();
		i.set((E) e);
	}
}


 List<String> list = Arrays.asList("B", "A", "C");

 // Comparator 인터페이스를 사용하여 람다식의 타겟 타입 결정
 // compareTo 메서드는 String 클래스의 메소드
 list.sort((s1, s2) -> s1.compareTo(s2));

 System.out.println(list); // ["A", "B", "C"]
  1. 람다식이 사용되는 컨텍스트를 분석해 해당 메서드의 시그니처(정의)를 분석한다. 이 경우 sort 메서드의 정의를 확인한다.

  2. sort메소드의 첫 번째 파라미터인 Comparator<? super E> c가 기대하는 대상 형식(target type)을 나타낸다

  3. Comparator 인터페이스는 int compare(T o1, T o2)라는 하나의 추상 메서드를 정의하는 함수형 인터페이스

  4. compare메서드는 두 개의 String 객체를 받아 int값을 반환하는 함수 디스크립터를 묘사

  5. 따라서 sort메서드로 전달된 인수는 이와 같은 요구사항을 만족해야함
    전달되는 인수인 (s1, s2) -> s1.compareTo(s2)는 (String s1, String s2) -> int의 형태를 띄고 있기에 유효하며 이렇게 형식 검사는 성공적으로 완료

함수 디스크립터(Function Descriptor)란?

함수형 인터페이스의 추상 메서드 시그니처를 묘사하는데 함수형 인터페이스는 하나의 추상 메서드만을 가지므로 해당 추상 메서드의 시그니처가 함수 디스크립더가 된다

ex) Comparator의 경우 추상 메서드 int compare(T o1, T o2)의 시그니처인 (T o1, T o2) -> int의 형태를 람다식은 가져야한다


변수 캡처(Variable Capture)

람다식은 Java 8에서 도입된 강력한 기능 중 하나로, 코드를 더욱 간결하고 가독성 있게 만들어준다. 그러나 람다식을 사용할 때 주의해야 할 점도 있는데, 그 중 하나가 변수 캡처(Variable Capture)

변수 캡처란 람다식 내부에서 람다식 외부에 선언된 변수를 참조하는 것을 말한다.
이는 매우 유용한 기능이지만 잘못 사용한다면 예기치 않은 결과가 발생할 수 있다.

람다식에서 접근 가능한 변수

  • 로컬 변수
  • 인스턴스 변수
  • 매개 변수
  • static 변수

1. 로컬 변수 캡처

  • Java 8이전에는 익명 내부 클래스에서 로컬 변수 캡처시 해당 변수가 final로 선언되어야함
    • 그렇지 않으면 컴파일 오류 발생
public class VariableCaptureExample {
    public static void main(String[] args) {
        String text = "Hello World";
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(text); // 컴파일 오류
            }
        };
        runnable.run();
    }
}
  • Java 8에서는 이러한 제약이 완화 되어 람다식에서 로컬변수 캡처시 명시적으로 final로 선언되지 않아도 된다. 다만, 해당 변수가 Effective Final이어야 하는데.
    • 즉, 해당 변수의 값이 변경되지 않아야 한다는 뜻
    • 초기화하고 값이 변경되지 않는 것을 의미
  • 다음 예시를 보자
// 해당 코드는 로컬 변수 i가 final하지 않아 컴파일 에러 발생함
public static void main(String[] args) {
        List<Runnable> runnables = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            runnables.add(() -> System.out.println(i));
        }
        for (Runnable runnable : runnables) {
            runnable.run();
        }
    }
    
// j 변수를 만들어 해결한다 (각 람다식 생성마다 새로 생성되기 때문)
public static void main(String[] args) {
        List<Runnable> runnables = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            int j = i;
            runnables.add(() -> System.out.println(j));
        }
        for (Runnable runnable : runnables) {
            runnable.run();
        }
    }

왜 이런 제약이 있을까?

로컬 변수는 쓰레드끼리 공유가 안되며 스택에 저장되는데, 이 경우 람다식이 실행하는 동안 지역 변수가 스택에서 사라진다면 문제가 발생할 수 있다.
따라서 람다식에서 로컬 변수의 값을 직접 참조하는게 아닌 복사된 값을 사용한다.
이러한 이유로 로컬 변수(매개변수도 마찬가지)는 final이거나 Effective Final이어야한다.

2. 인스턴스 변수 캡처

public class LambdaExample {
    int instanceVar = 10; // 인스턴스 변수(객체 변수)

    public void lambdaMethod() {
        int localVar = 20; // 로컬 변수
        Runnable r = () -> {
            System.out.println("localVar: " + localVar); // 로컬 변수 캡처
            System.out.println("instanceVar: " + instanceVar); // 인스턴스 변수 캡처
            instanceVar++; // 인스턴스 변수 값 변경 가능
        };
        r.run();
    }

    public static void main(String[] args) {
        new LambdaExample().lambdaMethod();
    }
}
  • 로컬 변수 캡처의 경우에는 값을 읽을 수 있지만 변경은 불가능. 반면 인스턴스 변수의 경우 값을 읽고 변경하는것이 가능하다
  • 인스턴스 변수는 힙 영역에 저장되기 때문에 람다식 실행동안 계속 존재함

3. 매개 변수 캡처

import java.util.function.Consumer;

public class LambdaExample {
    public void lambdaMethod(int x) { // 매개변수
        Consumer<Integer> c = (y) -> {
            System.out.println("x: " + x); // 매개변수 캡처
            System.out.println("y: " + y);
        };
        c.accept(10);
    }

    public static void main(String[] args) {
        new LambdaExample().lambdaMethod(20);
    }
}
  • 매개 변수 역시 값을 읽기만 하고 변경은 불가능하다

4. static 변수 캡처

public class LambdaExample {
    static int staticVar = 10; // static 변수

    public void lambdaMethod() {
        Runnable r = () -> {
            System.out.println("staticVar: " + staticVar); // static 변수 캡처
            staticVar++; // static 변수 값 변경 가능
        };
        r.run();
    }

    public static void main(String[] args) {
        new LambdaExample().lambdaMethod();
    }
}
  • 힙 영역에 저장되는 static 변수역시 값의 수정이 가능하다

클로저

: 함수와 그 함수가 선언된 어휘적 환경(Lexical Environment)의 조합

  • 즉, 외부 변수를 참조하고 있을 때 해당 변수가 속한 범위(scope)에서 벗어나도 해당 변수에 접근할 수 있다
  • 일반적으로 익명 함수의 형태로 사용된다
  • 람다식은 클로저의 일종(람다식에서 외부 변수를 참조하는 경우 해당 람다식은 클로저)
public class ClosureExample {
    public static void main(String[] args) {
        IntUnaryOperator add5 = makeAdder(5);
        IntUnaryOperator add10 = makeAdder(10);

        System.out.println(add5.applyAsInt(3)); // 8
        System.out.println(add10.applyAsInt(3)); // 13
    }

    public static IntUnaryOperator makeAdder(int x) {
        return y -> x + y;
    }
}
  • makeAdder함수는 x(5, 10)라는 매개 변수를 받아서 람다식을 반환
  • 이 람다식은 y라는 매개변수(3)를 받아서 x+y를 반환
  • 여기서 람다식은 외부 변수 x를 참조하기 때문에 클로저

쉐도잉

: 내부에 선언한 변수로 외부의 변수 값을 덮는 방식

  • 아래 코드는 익명 내부 클래스에서의 쉐도잉
public class ShadowingExample {
    int x = 10;

    public static void main(String[] args) {
        ShadowingExample outer = new ShadowingExample();
        outer.method();
    }

    void method() {
        Runnable runnable = new Runnable() {
            int x = 20;

            @Override
            public void run() {
                int x = 30;
                System.out.println(x); // 30
                System.out.println(this.x); // 20
                System.out.println(ShadowingExample.this.x); // 10
            }
        };
        runnable.run();
    }
}
  • 람다식에서는 쉐도잉이 불가능하다
  • 람다의 this는 자기 자신 클래스를 가리킨다고 위에서 말했다. 즉, 람다와 람다를 포함하는 메서드는 같은 범위(scope)다.
public static void main(String[] args) {
		int x = 10;
		Runnable runnable = () -> {
			int x = 20;
			// 컴파일 에러 발생: Variable 'x' is already defined in the scope
			System.out.println(x);
		};
        runnable.run();
	}

생성자, 메소드 레퍼런스

  • 메소드 및 생성자를 간결하게 지칭할 수 있는 방법
  • 일반 함수를 람다 형태로 사용할 수 있게한다

참조 방법

  • Default Use
  • Constructor Reference
  • Static Method Reference
  • Instance Method Reference

1. Default Use

@FunctionalInterface
interface ConverterInterface {
    String convert(Integer number);
}

public class MethodReferenceSample {
    public static void main(String[] args) {

        convert(100, (number) -> String.valueOf(number));

        convert(100, String::valueOf);
    }

    public static String convert(Integer number, ConverterInterface converterInterface) {
        return converterInterface.convert(number);
    }

}

2. Constructor Reference

  • 실제로 생성자 호출해서 인스턴스 생성 x
  • 생성자 메소드 호출
public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class Main {
    public static void main(String[] args) {
        // 생성자 메소드를 호출하는 람다식 Constructor Reference
        Function<String, Person> personFactory = Person::new;
        
        // Function 인터페이스의 apply 메소드를 호출하여 새로운 Person 객체를 생성
        Person person = personFactory.apply("John Doe", 30);
        
        // 생성된 Person 객체의 정보 출력
        System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
    }
}

3. Static Method Reference

public class Application {
	public static int square(int x) {
		return x * x;
	}

	public static void main(String[] args) {
		// static 메소드를 호출하는 람다식 Static Method Reference
		IntUnaryOperator squareFunction = Application::square;

		// IntUnaryOperator 인터페이스의 applyAsInt 메소드를 호출하여 값 계산
		int result = squareFunction.applyAsInt(5);

		// 결과 출력
		System.out.println(result); // 25
	}
}

4. Instance Method Reference

(매개변수) -> obj.instanceMethod(매개변수)
obj::instanceMethod

object::toString
() -> object.toString()

Reference

불변성
명령형 프로그래밍과 선언적 프로그래밍
타겟타이핑
익명구현클래스 vs 람다
https://velog.io/@yjw8459/Java-Lambda%EB%9E%8C%EB%8B%A4
https://sujl95.tistory.com/76
https://five-cosmos-fb9.notion.site/758e363f9fb04872a604999f8af6a1ae

profile
좋아하는걸 열심히

0개의 댓글