욱'S 노트

Java - Lamda expression 본문

JAVA/Pleasure

Java - Lamda expression

devsun 2016. 1. 13. 10:52

Java 8의 가장 주요한 변화는 더 빠르고, 명확하게 코딩할 수 있는 펑션 프로그래밍을 수행할 수 있게 되었다는 것이다.


자바는 1990년대에 객체지향 프로그래밍 언어로서 설계되었다. 그 당시 객체지향 프로그래밍은 소프트웨어 개발의 주요한 패러다임이였다. 객체지향 프로그래밍 훨씬 전에 Lisp 이나 Scheme 같은 함수형 프로그래밍 언어가 존재하였지만 학술적 영역외에서는 별다른 빛을 보지 못하였다. 최근 함수형 프로그래밍의 중요설이 대두되고 있는데 그 이유는 동시성과 이벤트 처리 프로그래밍에 적합히기 때문이다. 이 의미는 객체지향이 나쁘다는 것이 아니라, 객체지향과 함수형 프로그래밍을 적절히 사용하는 것이 좋은 전략이라는 것이다. 이것은 동시성에 관심이 없더라도 충분히 유의미하다. 예를 들어 컬렉션 라이브러리는 강력한 API를 제공하는데 이러한 함수형 표현은 훨씬 효율적인 문법을 가지고 있다.


자바8의 주요한 진화는 객체지향 기반에 함수형 프로그래밍을 추가하였다는 것이다. 이 글은 기본적인 문법을 설명하고 몇몇의 주요한 컨텍스트들을 어떻게 활용하는지에 대해 살펴본다. 주요한 점은 다음과 같다.


  • Lamda 표현식은 파라미터가 있는 코드 블럭이다.
  • 나중에 실행되어지는 코드 블럭을 원한다면 Lamda 표현식을 사용하자
  • Lamda 표현식은 함수형 인터페이스로 전환할 수 있다.
  • Lamda 표현식은 enclosing scope으로 부터 효율적으로 final 변수에 접근할 수 있다.
  • 메소드와 생성자 참조는 메소드와 생성자의 언급없이 호출할 수 있다.
  • 기본 메소드와 스태틱 메소드를 실제 구현을 제공하여 인터페이스에 추가할 수있다. 
  • 다수의 인터페이스의 기본 메소드 사이에 충돌은 해결해야 한다.


Why Lambdas? 


람다 표현식은 코드 블럭이다. 그래서 나중에 실행될 수 있고 한번 실행될 수 있고 여러번 실행될 수도 있다. 문법을 살펴보기 전에 자바에서 사용했던 코드 블럭을 살펴보자. 분리된 스레드에서 작업이 수행되기 원할때 우리는 작업을 Runnable의 run 스레드에 넣는다. 다음과 같이

public class Worker implements Runnable {
@Override
public void run() {
for (int i = 0 ; i < 1000 ; i++) {
doWork();
}
}

private void doWork() {

}
}

그런 다음 이 코드를 실행하기 위해 Worker 클래스의 인스턴스를 만든다. 그리고 스레드 풀에 인스턴스를 서브밋 해야 한다.

public static void main(String[] args) {
Worker worker = new Worker();
new Thread(worker).start();
}

중요한 포인트는 run 메소드에 코드가 포함되어 있으면 다른 스레드에서 실행되기를 원한다는 것이다. 커스텀 comparator에 의한 sorting을 고려해보자. 만약 기본적인 딕셔너리 순서 대신에 길이로 정렬을 하고 싶다면 다음과 같이 코딩할 수 있다.

public class LenghComparator implements Comparator<String> {
@Override
public int compare(String string1, String string2) {
return Integer.compare(string1.length(), string2.length());
}
}


또 다른 예는 디퍼드 실행이다. 버튼 콜백을 생각해보자. 당신은 listener 인터페이스를 구현한 클래스의 메소드에 콜백 액션을 집어넣을 것이다. 그리고 인스턴스를 생성하고 버튼의 인스턴스에 등록할 것 이다. 이러한 경우 자주 많은 프로그래머들들이 "anonymous instance of anonymous class" 문법을 사용한다.

Button button = new Button();

button.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
System.out.println("This button is clicked.");
}
});


위와 같은 모든 예제에서 우리는 똑같은 접근방법을 알 수 있다. 코드블럭이 누군가에게 전달되었다는 것이다. - 스레드 풀, 소트 메소드 또는 버튼. 코드는 나중에 호출될 것이다.


지금까지 자바에서 코드 블럭을 누군가에게 전달하는 것은 쉽지 않은 일이었다. 단순히 코드 블럭을 전달할 수 있는 방법이 없었다. 자바는 객체지향 언어이다. 그래서 클래스를 생성해야 하고 거기에다가 원하는 코드를 메소드로 작성해야 한다. 다른 언어에서는 코드 블럭을 직접적으로 전달하는 것이 가능했다. 다음 섹션에서는 자바 8에서 코드 블럭을 어떻게 동작하게 할 수 있는지 살펴보겠다.


The Syntax of Lambda Expressions


정렬 예제를 다시 한번 살펴보자. 우리는 하나의 문자열의 길이가 다른 것보다 짧은지를 체크하는 코드를 전달했다.

Integer.compare(string1.length(), string2.length());

string1과 string2는 무엇인가? 둘다 문자열이다. 자바는 strong typed 언어이다. 다음과 같이 코딩할 수 있다.

(String string1, String string2) -> Integer.compare(string1.length(), string2.length())

첫번째 람다 표현식이다. 이러한 표현식은 단순한 코드 블럭이다. 어떤 변수를 명시함으로써 코드를 전달시킬 수 있다.


자바에서 람다 표현식의 하나의 형태를 살펴보았다. 파라미터, 화살표 -> 그리고 표현식이다.  만약 코드가 하나의 표현식으로 안된다면 블럭 {}을 사용할 수 있다. 다음과 같은 예이다.

(String string1, String string2) -> {
if (string1.length() < string2.length())
return -1;
else if (string1.length() > string2.length())
return 1;
else
return 0;
});


만약 람다표현식에 파라미터가 필요없다면 빈괄호로 두면 된다.

() -> {
for (int i = 0; i < 1000 ; i++) {
doWork();
}
}


만약 파라미터 타입을 추론할 수 있다면 생략할 수 있다.

Comparator<String> comparator = (string1, string2) -> Integer.compare(string1.length(), string2.length());

여기서 컴파일러는 string1과 string2는 문자열로 추론할 수 잇다. 때문에 람다 표현식은 string comparator에 할당된다. 


만약 메소드가 하나의 파라미터이고 추론될 수 있다면 괄호도 생략할 수 있다.

EventHandler<ActionEvent> handler = (event) -> System.out.println("This button is clicked.");

그리고 final이나 어노테이션도 같이 붙여서 전달할 수 있다. 


그리고 람다표현식의 결과 타입을 명시할 필요가 없다. 이것은 항상 컨텍스트에 의해 추론될 수 있기 때문이다. 예를 들어 아래 표현식에서는 리턴타입이 int라는 것을 추론할 수 있다.

(String string1, String string2) -> Integer.compare(string1.length(), string2.length())


Functional Interfaces


논의한바와 같이 자바에서는 매우 많은 인터페이스들이 존재한다. 이러한 인터페이스들은 코드 블럭을 캡슐화하는데 Runnable 또는 Comparator가 그 예이다. 람다는 이러한 인터페이스에 적합하다.


하나의 추상화 메소드를 가진 인터페이스들은 람다표현식으로 채울수 있다. 이러한 인터페이스를 functional interface라고 부른다.


당신은 왜 functional interface가 하나의 추상화 메소드를 가져야 하는지 궁금할 것이다. 인터페이스는 모든 메소드는 abstract가 아닌가? 사실 Object 클래스의 toString 또는 clone 과 같은 메소드들은 항상 재정의할 수 있으며 이러한 메소드들은 abstract 메소드로 정의되지 않았다. 더 중요한 점은 자바 8에서 인터페이스는 abstract 메소드가 아닌 메소드들을 정의할 수 있다.


Functional interface에 대한 논의를 설명하기 위해서 Arrays.sort 메소드를 살펴보자. 두번째 파라미터로 Comparator의 인스턴스를 필요로 하고 인터페스는 하나의 메소드들 가지고 있다. 간단하게 람다로 채운다면 다음과 같다.

Arrays.sort(strings, (string1, string2) -> Integer.compare(string1.length(), string2.length()));

위의 내용을 살펴보면 Arrays.sort 메소드는 Comparator<String>을 구현한 어떤 클래스의 객체를 전달받아야 한다. 그러나 람다로 표현했을때 이러한 객체와 클래스의 관리는 구현과 완전히 독립적이 되며, 전통적인 inner 클래스 방식보다 훨씬 더 효율적이다. 람다는 객체가 아니라 function으로 생각하는 것이 맞다. 그리고 람다는 functional interface로 전달될 수 있다.


인터페이스의 이러한 변화는 람다를 더 받아들이기 쉽게 만든다. 문법은 짧고 단순하다.

button.setOnAction(event -> System.out.println("This button is clicked."));

얼마나 읽기 쉽고 좋은가?


사실 functional interface로 전환이 자바에서 람다를 써야할 유일한 이유이다. 다른 언어에서는 function 리터럴을 제공한다. 자바에서는 람다 표현식을 오브젝트의 변수로 할당할 필요가 없으며, 이는 오브젝트가 아니라 functional interface이기 때문이다. 


자바 API는 몇몇의 제너릭 functional interface를 정의하고 있다. BiFunction<T,U,R> 이 하나의 예인데 이것은 파라미터 타입 T, U 그리고 리턴타입이 R이라고 서술하고 있다. string 비교 람다를 해당 타입에 저장할 수 있다.

BiFunction<String,String,Integer> function = (string1, string2) -> Integer.compare(string1.length(), string2.length());

하지만 이러한 것은 정렬에 도움이 되지 못한다. 이유는 Arrays.sort 메소드가 BiFunction을 원하지 않기 때문이다. 만약 이전에 functional 프로그래밍에 경험이 있다면 궁금중이 생길 것이다. 그러나 자바 프로그래머에게는 매우 자연스러운 일이다. Comparator와 같은 인터페이스는 특정한 목적을 가지고 있으며, 단지 파라미터와 리턴타입을 메소드와 함께 제공하는 것이 아니다. 자바8에서는 조미료가 생겼다.


java.util.function의 인터페이스는 몇몇 자바 8 API들에서 사용되고 있다. 그러나 명심해라. 람다 표현식은 functional interface로 잘 변경할 수 있다. 또한 어떤 functional interface에 @FunctionInterface 어노테이션으로 태그할 수 있다. 이것은 두가지 이점이 있다. 컴파일러는 어노테이션된 엔티티가 single abstract method를 가진 인터페이스인지 확인한다. 그리고 javadoc 페이지에 당신의 인터페이스가 functional interface이라고 표시된다. 어노테이션을 꼭 사용할 필요는 없다. 그러나 @FunctionInterface 어노테이션을 붙이는 것은 좋은 아이디어이다.


마지막으로 람다를 functional interface의 인스턴스로 전환했을때 checked exception에 주의하자. 만약 람다가 checked exception을 발생한다면 exception은 타겟 인터페이스의 abstract 메소드에 정의해야 한다. 다음 예제는 에러를 유발할 것이다.

Runnable sleeper = () -> {
System.out.println("zzz");
Thread.sleep(1000);
};

이유는 Runnble의 run메소드가 exception을 throw하지 않아서 이러한 할당은 옳지 않다. 에러를 수정하기 위해 두가지 선택을 해야하는데 람다에서 exception을 처리하던지 람다가 할당되는 인터페이스의 메소드가 exception을 throw 해야 한다.


Method References


때때로 메소드만 전달했을 경우에도 정확한 동작을 수행할 수 있을 경우가 있다. 예를 들어 버튼이 클릭되었을때 이벤트를 출력하는 코드를 본다면 다음과 같다.

button.setOnAction(event -> System.out.println(event));

그러나 위와 같은 코드의 더욱 단순해질수 있는데 그것은 event 자체가 추론될 수 있기 때문이다.

button.setOnAction(System.out::println);


System.out::println 이라는 표현식이 method reference이며 이것은 x -> System.out.println(x)라는 람다 표현식과 동일하다.


또 다른 예제는 문자열을 대소문자 구분없이 정렬을 하고 싶을때의 예제이다.

Arrays.sort(strings, String::compareToIgnoreCase);

이러한 예제들을 봤을때, :: 연산자는 오브젝트나 클래스와 메소드의 구분자임을 알 수 있다.


  • object::instanceMethod

  • Class::staticMethod

  • Class::instanceMethod


첫번째 두가지 케이스에서는 메소드 레퍼런스는 파라미터가 전달된 람다 표현식과 같다. 이미 언급한 바와 같이 System.out::println은  x -> System.out.println(x)과 동일하다. 비슷하게 Math::pow는 (x,y) -> Math.pow(x,y)와 같다. 세번째 케이스는 첫번째 파라미터가 메소드의 타겟이 되어야 한다. 즉  String::compareToIgnoreCase는 (x, y) -> x.compareToIgnoreCase(y)과 동일하다.


같은 이름의 다양하게 메소드가 오버로드외어 있다면 컴파일러는 context에 따라서 메소드를 찾아낼려고 노력할 것이다. 예를 들어 Math.max 메소드의 두가지 버젼이 있고 하나는 integer고 또 다른 하나는 double에 대한 것이다. 이럴 경우 컴파일러는 functional interface의 파라미터에 따라서 Math::max를 선택할려고 할 것이다. 


우리는 또한 메소드 레퍼런스의 파라미터를 확보할 수 있다. 예를 들어 this:equals는 x -> this.equals(x)와 동일하다. 이것은 super를 사용할떄도 동일하다. 

public class Greeter {
public void greet() {
System.out.println("Hello");
}

public class CocurrentGreeter extends Greeter {
public void greet() {
new Thread(super::greet).start();
}
}
}

스레드가 시작될때 Runnable이 실행되고 super::greet가 실행된다.


Constructor References


Constructor references는 메소드 레퍼런스와 같다. 다른 점이 있다면 메소드의 이름이 new라는 것이다. 예를 들어 Button:new는 Button 생성자에 대한 레퍼런스이다. 어떤 생성자가 선택되느냐? 이것 역시 context에 따른 것이다. 문자열 리스트를 가지고 있다고 가정해보자. 그리고 버튼의 배열로 변경하는데 각 문자열들을 생성자를 호출할때 사용한다고 가정해보자.

List<String> labels = new ArrayList<>();
Stream<Button> stream = labels.stream().map(Button::new);
List<Button> buttons = stream.collect(Collectors.toList());

stream, map, collect 메소드의 자세한 언급은 이 글에서 하지 않겠다.


우리는 생성자 레퍼런스를 배열 타입과 함께 구성할 수도 있다. 예를 들어 int[]::new는 하나의 파라미터에 대한 생성자 레퍼런스이며 그것은 array의 길이이다. 이것은 x->new int[x]와 동일하다.


Array 생성자는 자바의 한계를 극복하는데 유용하다. array 생성자에는 제너릭을 사용할 없다. 이러한 문제는 라이브러리 개발자를 곤혹스럽게 만든다. 예를들어 버튼의 배열을 원할때 Stream 인터페이스의 toArray함수는 Object 배열을 리턴한다.

Object[] objects = stream.toArray();


불행하게도 우리가 원하는것은 오브젝트 배열이 아니라 Button의 배열이다. 해결책은 아래와 같다.

Button[] buttons = stream.toArray(Button[]::new);


Variable Scope


종종 우리는 근접한 메소드나 클래스의 변수를 람다 표현식에서 접근하고 싶을 경우가 있다.

public static void repeatMessage(String text, int count) {
Runnable r = () -> {
for (int i = 0 ; i < count ; i++) {
System.out.println(text);
Thread.yield();
}
};

new Thread(r).start();
}

람다 표현식 내에서 count와 text변수를 사용한 것을 알 수 있다. 이러한 변수들은 람다 표현식에서 정의하지 않은 것임에 주의하자. 


어떠한 일이 발생할지 이해하기 위해 람다 표현식에 대한 이해를 다시 정리할 필요가 있다. 람다 표현식은 세가지 요소를 가지고 있다.


  • A block of code

  • Parameters

  • Values for the free variables; 즉 변수인데 파라미터가 아니며 코드내에서 정의되지 않은 것


우리의 예제에서는 람다 표현식은 두가지의 외부 변수를 가지고 있다. - text, count. 람다 표현식을 나타내는 데이터 구조는 이러한 변수를 값으로 저장해야 한다. 우리의 경우 "Hello"와 1000이다. 우리는 이러한 값을 람다 표현식에 저장한다고 말한다. 


외부 변수의 값을 사용하는 코드 블럭의 기술적인 의미는 클로져이다. 만약 누군가가 그들의 언어에 클로져가 있다고 자랑스러워한다면 자바도 있다고 말하라. 자바에서 람다 표현식은 클로져이다. 사실 inner 클래스 자체가 크로져일수도 있다. 


보시다시피 람다 표현식에서는 근접한 스코프의 변수의 값을 저장할 수 있다. 자바에서는 잘 정의된 중요한 제약조건이 있다. 람다 표현식에서 참조한 외부 변수는 변경될 수 없다.

public static void repeatMessage(String text, int count) {
Runnable r = () -> {
while(count > 0) {
System.out.println(text);
Thread.yield();
count--;
}
};

new Thread(r).start();
}

위와 같은 경우는 허용되지 않는다. 람다 표현식에서 외부 변수의 변경은 스레드 세이프하지 않다. 

int matches = 0;

for (Path p : files) {
new Thread(() -> {
if (p != null)
matches++;
});
}

이와 같은 코드는 합법적이나 매우 매우 나쁘다.  matches++는 atomic이 아니기 때문에 다수의 스레드에서는 동시에 값을 증가 시키면 무슨일이 발생할지 모른다.


내부 클래스는 외부 스코프로부터 온 값을 사용할 수 있다. 자바 8 이전에 내부 클래스는 final 로컬변수만 접근할 수 있었는데 이러한 규칙은 람다 표현식을 위해 완화되었다. 


이상하게 들릴지 모르지만 공유된 객체를 변경하는 것도 가능하다. 

List<Path> matches = new ArrayList<>();

for (Path p : files)
new Thread(() -> {
if (p != null)
matches.add(p);
});

이 경우 matches는 항상 같은 ArrayList 객체를 참조한다. 하지만 오브젝트는 변경되고, 이것은 스레드 세이프하지 않다. 다수의 스레드에서 add를 수행한다면 결과는 예측할 수 없다.


동시성에서 카운트하고 수집하는 메커니즘은 존재한다. 그러나 우리의 상황에서는 스레드 세이프한 카운터나 컬렉션을 사용할 수 있을 것이다.


람다 표현식는 몸체는 인접한 블럭과 동일한 스코프이다. 람다에서 파라미터와 같은 이름의 로컬 변수가 존재한다면 이는 허용되지 않을 것이다.

Path first = Paths.get("/usr/bin");

Comparator<String> comparator = (first, second) -> Integer.compare(first.length(), second.length());

메소드내에서 동일한 이름의 두개의 로컬 변수는 가질 수 없다. 그러므로 람다 표현식 또한 그러한 변수들을 사용할 수 없다. 람다 표현식에서 this 키워드를 사용한다면 메소드의 this 파라미터를 언급해야 된다.

public class Application {
public void doWork() {
Runnable runnable = () -> System.out.println(this.toString());
}
}

표현식에서 this.toString()을 호출하는데 이것은 Application 객체의 toString 메소드를 호출하게 된다. 절대 Runnable 인터페이스가 아니다. 이러한 사용은 람다 표현식에서 당연한 것이다. 


Default Methods


많은 프로그래밍 언어에서 그들의 컬렉션 라이브러리에 함수형 표현이 포함되어 있다. 이러한 경우 종종 루프보다 더 짧고 이해하기 쉬운 코드로 작성할 수 있다.

for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}

더 좋은 방법이 존재하는데 forEach는 각 엘레먼트에 함수를 적용할 수 있다.

list.forEach(System.out::println);

자바 컬렉션 라이브러리는 매우 오랜 시간전에 디자인되었고, 몇몇의 문제를 가지고 있다. 만약 Collection 인터페이스가 forEach와 같은 새로운 메소드를 가지게 된다면 모든 Collection 인터페이스를 구현한 프로그래밍에서 해당 메소드를 상속해야 하는데 자바에서 단순히 받아드릴 수는 없을 것이다.


그래서 자바 디자이너들은 이러한 문제를 풀기 위해 default 메소드를 고안하데 되었다. 이러한 메소드들은 기존 존재하는 인터페이스에 안전하게 추가될 수 있게 되었다. 이러한 연유로 자바 8에서는 forEach가 Iterable 인터페이스에 추가될 수 있었다.

public interface Person {
long getId();
default String getName() {
return "John Q. Public";
}
}

인터페이스는 두개의 메소드를 가지고 있다. getId라는 추상 메소드와 getName이라고 하는 디폴트 메소드이다. Person 인터페이스를 추상 메소드는 당연히 구현해야 하며 getName은 그대로 유지할 수도 오버라이드 할 수도 있게 되었다.


디폴트 메소드는 인터페이스나 추상 클래스의 마직막에 추가 되었다. 이제 우리는 인터페이스에 메소드를 구현할 수 있게 되었다.


하나의 인터페이스 추가된 똑같은 메소드가 수퍼클래스나 또 다른 인터페이스에 있다면 어떤 일이 벌어질까? Scala나 C++에서는 이런 애매모호함을 해결하기 위해 복잡한 룰을 가지고 있는데 다행히도 자바에서도 좀더 심플하다.


1. Superclasses win. 만약 수퍼클래스가 구현메소드를 제공한다면 동일한 메소드 시그니처를 가진 default 메소드는 무시된다.

2. Interfaces clash. 만약 수퍼 인터페이스가 default 메소드를 제공하고, 또다른 인터페이스에서 같은 메소드 시그니처에 메소드를 제공한다면 그때는 반드시 구현 클래스에서메소드를 오버라이딩해야 한다.


두번째 룰을 살펴보자.

public interface Named {
default String getName() {
return getClass().getName() + "_" + hashCode();
}
}
public class Student implements Person, Named {
@Override
public long getId() {
return 0;
}

@Override
public String getName() {
return Person.super.getName();
}
}

위와 같이 Person과 Named를 구현한 Student클래스에서는 getName 메소드를 오버라이드 해야한다.

public interface Named {
String getName();
}

위와 같이 default 메소드로 지정되어 있지 않다고 가정해보자.


Student 클래스는 Person인터페이스로부터 default 메소드를 상속받을수 있지 않느냐? 이것은 타당한것 처럼 보인다. 하지만 자바 디자이너들은 일관성을 선호하는 쪽으로 선택했다. 적어도 하나의 인터페이스가 구현을 하라고 메소드를 제공하면 컴파일러는 에러를 리포팅하고 프로그래머는 반드시 애매모호함을 해소해야 한다.solve the ambiguity.


자바8 이전에는 default 메소드를 제공하지 않았기 대문에 이러한 출동은 존재하지 않았다. 구현 클래스는 두가지 선택이 존재한다. 메소드를 구현하거나 구현하지 않는 것이다. 이 경우 클래스는 abstract가 되어야 한다.


지금까지 두개의 인터페이스간의 충돌에 대해 논의를 하였다. 이제 수퍼클래스와 인터페이스 간의 참조를 살펴보자.

public class Student extends Person implements Named {

}

이 경우 수퍼클래스에서 메소드가 제공되었기 때문에 인터페이스의 메소드는 무시된다. 이것은 "class wins" 룰이다. 이는 자바 7에서 확정되었다.


Static Methods in Interfaces


자바 8에서는 인터페이스에 스태틱 메소드를 추가할 수 있다. 원래 기술적인 이슈는 없었다. 추상화명세로 인터페이스의 정신에 반하는 것으로 보였기 때문이다.


이제부터 companion 클래스들을 위한 static 메소드를 추가할 수 있다. 


Path 인터페이스를 살펴보자. 여기는 팩토리 메소드들이 있는데 문자열들로 path를 생성할 수 있다.

public interface Path {
public static Path get(String first, String... more) {
return FileSystems.getDefault().getPath(first, more);
}
...
}

자바 8에서는 상당히 많은 static 메소드가 인터페이스에 추가되었다. 


출처 : http://www.drdobbs.com/jvm/lambda-expressions-in-java-8/240166764?pgno=1

'JAVA > Pleasure' 카테고리의 다른 글

Java - Nashorn  (1) 2016.03.10
Java - Stream  (0) 2016.01.14
NIO란?  (0) 2015.04.17
Stack과 heap의 차이점  (0) 2015.01.12
Comments