
(int n) -> n + 9는 도대체 무엇일까? | Runnable부터 Java 람다와 예외 처리까지Java 문제를 풀다가 갑자기 이런 코드가 등장했다.
run((int n) -> n + 9)
처음 봤을 때 가장 먼저 든 생각은
->는 도대체 뭐지?
였다.
Java에서 메서드라면 보통 이렇게 생겼다.
int add(int n) {
return n + 9;
}
그런데 문제에서는 메서드 이름도 없이
(int n) -> n + 9
라고 적혀 있었다.
더 복잡한 문제에서는 다음과 같은 코드까지 등장했다.
F f = (x) -> {
if (x > 2) {
throw new Exception();
}
return x * 2;
};
처음에는 이것이 F와 무슨 관계가 있는지, apply()는 어디로 사라졌는지, run(f)를 호출하면 무엇이 실행되는지 이해하기 어려웠다.
그런데 이 문제를 이해하려면 먼저 Java에서 동작 자체를 객체로 전달하는 방식부터 생각해볼 필요가 있었다.
Java에서 다음과 같은 코드를 볼 수 있다.
class Car implements Runnable {
@Override
public void run() {
System.out.println("run");
}
}
Runnable은 인터페이스다.
핵심적으로 실행할 작업을 run()이라는 메서드로 표현한다.
public interface Runnable {
void run();
}
따라서
class Car implements Runnable
이라고 하면 Car는 Runnable이 요구하는
run()
을 구현해야 한다.
class Car implements Runnable {
public void run() {
System.out.println("run");
}
}
즉 Car 객체는 단순히 자동차라는 데이터만 표현하는 것이 아니라 실행할 작업을 가진 객체로 사용할 수 있다.
다음 코드를 보자.
Thread t = new Thread(new Car());
처음에는 Thread 생성자 안에 왜 Car 객체를 넣을 수 있는지 이상했다.
하지만 Car는
implements Runnable
을 했다.
따라서 Car 객체는 Runnable 타입으로 사용할 수 있다.
개념적으로
Runnable task = new Car();
가 가능한 것이다.
그리고 Thread는 실행할 작업을 Runnable 형태로 전달받을 수 있다.
new Car()
↓
Runnable 구현 객체
↓
Thread에 전달
↓
Thread가 실행할 작업을 가짐
그래서
Thread t = new Thread(new Car());
가 가능하다.
start()와 run()은 다르다여기서 시험에서 자주 헷갈리는 부분이 하나 있다.
t.start();
와
t.run();
은 같지 않다.
start()는 새로운 스레드의 실행을 시작한다.
t.start()
↓
새로운 스레드 시작
↓
run() 실행
반면
t.run();
을 직접 호출하면 일반적인 메서드 호출처럼 동작한다.
즉
start()
→ 새로운 스레드의 실행을 시작
run()
→ run() 메서드를 직접 호출
이라고 구분해야 한다.
이 부분을 이해하고 나니 Runnable이 단순히 run()이라는 이름을 외우는 인터페이스가 아니라 실행할 동작을 하나의 객체로 표현하는 방식이라는 것이 조금씩 보이기 시작했다.
그리고 이 생각이 람다식으로 연결된다.
다음 인터페이스가 있다고 하자.
interface F {
int apply(int x) throws Exception;
}
F에는 구현해야 할 추상 메서드가 하나 있다.
int apply(int x) throws Exception;
이처럼 람다의 대상으로 사용할 수 있는 추상 메서드가 하나인 인터페이스를 함수형 인터페이스라고 한다.
여기서 중요한 것은 F를 구현하는 객체가 사실상 하나의 동작을 가지고 있다는 것이다.
F
└── apply(int x)
그렇다면 이 인터페이스를 구현하려면 원래 어떻게 해야 할까?
가장 익숙한 방법은 클래스를 하나 만드는 것이다.
class MyFunction implements F {
@Override
public int apply(int x) throws Exception {
if (x > 2) {
throw new Exception();
}
return x * 2;
}
}
그리고
F f = new MyFunction();
이라고 하면 된다.
이제
f.apply(3);
을 호출하면 MyFunction에서 구현한 apply()가 실행된다.
하지만 이 동작 하나를 사용하기 위해 클래스를 따로 만드는 것이 번거로울 수도 있다.
별도의 클래스 이름이 필요하지 않다면 익명 클래스를 사용할 수도 있다.
F f = new F() {
@Override
public int apply(int x) throws Exception {
if (x > 2) {
throw new Exception();
}
return x * 2;
}
};
별도의 MyFunction 클래스를 만들지는 않았지만 F의 apply()를 구현한 객체를 바로 만들었다.
그런데 이것도 상당히 길다.
우리가 정말 표현하고 싶은 핵심은 사실 다음 부분이다.
int apply(int x) throws Exception {
if (x > 2) {
throw new Exception();
}
return x * 2;
}
그리고 F에는 구현할 추상 메서드가 하나뿐이다.
그렇다면 Java 입장에서는 어떤 메서드를 구현하려는 것인지 이미 알 수 있다.
그래서 이 부분을 더 간결하게 표현할 수 있다.
그것이 람다식(Lambda Expression)이다.
익명 클래스 형태의 다음 코드를
F f = new F() {
@Override
public int apply(int x) throws Exception {
if (x > 2) {
throw new Exception();
}
return x * 2;
}
};
람다로 표현하면 다음과 같이 간단해진다.
F f = (x) -> {
if (x > 2) {
throw new Exception();
}
return x * 2;
};
처음에는 apply라는 이름이 사라져서 이상했다.
하지만 F에는 람다의 대상이 되는 추상 메서드가 하나뿐이다.
int apply(int x) throws Exception;
따라서 Java는 이 람다가 apply()에 해당하는 동작이라는 것을 알 수 있다.
개념적으로는
F f = (x) -> x * 2;
를 볼 때
F의 추상 메서드
↓
apply(int x)
↓
구현 내용
↓
return x * 2;
라고 연결해서 생각하면 된다.
(int n) -> n + 9는 어떻게 읽을까?이제 처음에 이해되지 않았던 코드를 다시 보자.
(int n) -> n + 9
처음에는 문법을 세세하게 분석하기보다 다음처럼 읽는 것이 가장 쉬웠다.
int n을 받아서 n + 9를 반환한다.
즉
입력
↓
n
↓
n + 9
↓
결과
다.
예를 들어
(int n) -> n * 2
라면
int n을 받아서 n의 2배를 반환한다.
이고,
(int n) -> n + 9
라면
int n을 받아서 9를 더한 값을 반환한다.
라고 읽으면 된다.
다음 람다를 보자.
(int n) -> n + 9
여기에는 return이 없다.
하지만 값을 반환하는 표현식 하나로 구성된 람다는 결과를 그대로 반환하는 형태로 사용할 수 있다.
따라서 개념적으로
(int n) -> n + 9
는
(int n) -> {
return n + 9;
}
와 연결해서 이해할 수 있다.
그리고 F의 apply()와 연결하면
int apply(int n) {
return n + 9;
}
와 같은 동작이라고 생각할 수 있다.
다음 두 표현을 볼 수 있다.
(int n) -> n + 9
(n) -> n + 9
상황에 따라 두 번째처럼 타입이 생략될 수도 있다.
왜냐하면 람다가 어떤 함수형 인터페이스를 대상으로 하는지 알면 매개변수 타입도 알 수 있기 때문이다.
예를 들어
interface F {
int apply(int x) throws Exception;
}
이라면 apply()의 매개변수는 이미
int
라고 정의되어 있다.
그래서
F f = (x) -> x * 2;
에서 x가 int라는 것을 추론할 수 있다.
문제는 다음과 같다.
public class Main {
static interface F {
int apply(int x) throws Exception;
}
public static int run(F f) {
try {
return f.apply(3);
} catch (Exception e) {
return 7;
}
}
public static void main(String[] args) {
F f = (x) -> {
if (x > 2) {
throw new Exception();
}
return x * 2;
};
System.out.print(
run(f) + run((int n) -> n + 9)
);
}
}
처음에는 코드가 복잡해 보이지만 람다를 apply()의 구현이라고 생각하면 하나씩 따라갈 수 있다.
run(f)를 계산해보자run()은 다음과 같다.
public static int run(F f) {
try {
return f.apply(3);
} catch (Exception e) {
return 7;
}
}
즉 F 객체를 하나 전달받아서
f.apply(3);
을 호출한다.
현재 전달한 f는 다음 람다다.
F f = (x) -> {
if (x > 2) {
throw new Exception();
}
return x * 2;
};
이를 apply()처럼 펼쳐 생각해보면
int apply(int x) throws Exception {
if (x > 2) {
throw new Exception();
}
return x * 2;
}
와 같은 동작이다.
apply(3)을 실행하면run(f) 내부에서는
f.apply(3);
을 호출한다.
따라서
x = 3
이다.
조건을 확인한다.
if (x > 2)
즉
3 > 2
이므로 참이다.
따라서
throw new Exception();
이 실행된다.
흐름은 다음과 같다.
run(f)
↓
f.apply(3)
↓
x = 3
↓
3 > 2
↓
true
↓
Exception 발생
f.apply(3)은 try 안에서 실행되고 있다.
try {
return f.apply(3);
} catch (Exception e) {
return 7;
}
따라서 Exception이 발생하면 catch로 이동한다.
Exception 발생
↓
catch (Exception e)
↓
return 7
결국
run(f)
의 결과는
7
이다.
두 번째 호출은 다음과 같다.
run((int n) -> n + 9)
처음에는 run()의 매개변수가 F인데 어떻게 갑자기 람다를 바로 넣을 수 있는지 헷갈렸다.
run()을 다시 보면
public static int run(F f)
다.
즉 F 타입의 동작을 전달해야 한다.
그리고 F는
interface F {
int apply(int x) throws Exception;
}
라는 하나의 추상 메서드를 가지고 있다.
따라서
(int n) -> n + 9
는 그 apply()를 구현하는 동작으로 사용할 수 있다.
개념적으로
run((int n) -> n + 9)
를 다음처럼 생각할 수 있다.
F temp = (int n) -> n + 9;
run(temp);
run()의 결과run()에서는 항상
f.apply(3);
을 호출한다.
이번 f의 동작은
(int n) -> n + 9
다.
따라서
n = 3
이 들어간다.
3 + 9 = 12
예외도 발생하지 않는다.
따라서
run((int n) -> n + 9)
의 결과는
12
다.
원래 코드는
System.out.print(
run(f) + run((int n) -> n + 9)
);
였다.
앞에서 각각 계산했다.
run(f)
→ 7
그리고
run((int n) -> n + 9)
→ 12
따라서
7 + 12
= 19
최종 출력은
19
다.
처음에는 람다 문법을 그대로 따라가려고 해서 더 어려웠다.
오히려 람다를 만나면 원래 메서드 형태로 다시 펼쳐서 생각하는 것이 편했다.
예를 들어
F f = (x) -> x * 2;
가 있고
interface F {
int apply(int x);
}
라면 머릿속에서
int apply(int x) {
return x * 2;
}
로 바꿔본다.
그러면
f.apply(3);
도 바로 이해된다.
x = 3
↓
3 * 2
↓
6
즉 시험에서는
람다 발견
↓
대상 인터페이스 확인
↓
추상 메서드 확인
↓
람다를 그 메서드의 구현으로 생각
↓
실제 전달되는 값 대입
↓
결과 계산
순서로 따라가면 된다.
+ 연산도 같이 헷갈렸다Java 문제를 풀면서 람다 외에도 출력 결과에서 자주 헷갈렸던 것이 문자열의 + 연산이었다.
예를 들어
int r1 = 9;
int r2 = 2;
char r3 = '3';
System.out.println(r1 + r2 + "2" + r3);
가 있다고 하자.
처음부터 전부 문자열로 합치는 것이 아니다.
Java의 + 연산은 왼쪽부터 진행된다.
먼저
r1 + r2
를 계산한다.
9 + 2 = 11
아직 둘 다 숫자이므로 숫자 덧셈이다.
그다음
11 + "2"
를 계산한다.
여기서 String을 만났다.
따라서 문자열 연결이 된다.
"112"
그리고
"112" + '3'
도 문자열 연결이 된다.
결과는
"1123"
이다.
1 + 2 + "3"과 "1" + 2 + 3이 차이는 시험에서 특히 주의해야 한다.
먼저
System.out.println(1 + 2 + "3");
를 보자.
왼쪽부터 계산한다.
1 + 2
↓
3
3 + "3"
↓
"33"
따라서
33
이 출력된다.
반면
System.out.println("1" + 2 + 3);
에서는 처음부터 String이 등장한다.
"1" + 2
↓
"12"
"12" + 3
↓
"123"
따라서
123
이 출력된다.
둘의 차이는 결국 String을 어느 시점에 만나는가다.
처음에는 Runnable과 람다가 서로 다른 문법처럼 느껴졌다.
하지만 둘 다 어떤 동작을 전달한다는 관점으로 보면 연결된다.
Runnable에는 실행할 동작을 나타내는
void run()
이 있다.
그래서 다음처럼 익명 클래스를 만들 수 있다.
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
그리고 Runnable은 람다의 대상으로 사용할 수 있는 형태이므로 이 동작을 더 간단하게 표현할 수도 있다.
Runnable task = () -> System.out.println("Hello");
Thread에 전달하면
Thread t = new Thread(task);
t.start();
처럼 사용할 수 있다.
더 줄이면
Thread t = new Thread(
() -> System.out.println("Hello")
);
t.start();
와 같은 형태도 이해할 수 있게 된다.
처음에는
() -> ...
가 갑자기 등장한 이상한 문법처럼 보였지만 결국
Runnable의
run()에서 실행할 내용을 전달한 것
이라고 생각할 수 있었다.
람다가 아직 익숙하지 않다면 다음 세 단계를 비교해보는 것이 가장 이해하기 쉬웠다.
class MyFunction implements F {
@Override
public int apply(int x) {
return x * 2;
}
}
F f = new MyFunction();
F f = new F() {
@Override
public int apply(int x) {
return x * 2;
}
};
F f = (x) -> x * 2;
표현은 점점 짧아지지만 핵심적으로 표현하려는 동작은
x를 받아서 x * 2를 반환한다.
로 같다.
람다 문제가 나오면 다음 순서로 보는 것이 가장 편했다.
1. 람다가 어떤 인터페이스 타입으로 사용되고 있는가?
2. 그 인터페이스의 추상 메서드는 무엇인가?
3. 매개변수 타입과 반환 타입은 무엇인가?
4. 람다를 그 메서드의 구현이라고 생각한다.
5. 실제로 어떤 값이 전달되는지 확인한다.
6. 예외가 발생하는지 확인한다.
7. try-catch가 있다면 어디에서 처리되는지 확인한다.
예를 들어
run((int n) -> n + 9)
를 보면
run()의 매개변수
↓
F
F의 추상 메서드
↓
int apply(int x)
람다
↓
(int n) -> n + 9
run() 내부
↓
f.apply(3)
따라서
↓
n = 3
결과
↓
3 + 9 = 12
로 따라가면 된다.
처음에는
(int n) -> n + 9
라는 표현 자체가 이해되지 않았다.
메서드 이름도 없고 return도 없는데 어떻게 실행되는지 감이 오지 않았다.
하지만 함수형 인터페이스와 연결해서 보니 훨씬 단순했다.
interface F {
int apply(int x) throws Exception;
}
처럼 구현해야 할 추상 메서드가 하나라면
(x) -> x * 2
라는 람다를
int apply(int x) {
return x * 2;
}
라는 동작의 구현으로 연결해서 생각할 수 있었다.
그리고
run(f)
처럼 람다를 전달받는 메서드에서는 결국 그 동작을
f.apply(3);
처럼 실행하고 있는 것이다.
이번 내용을 한 흐름으로 정리하면 다음과 같다.
함수형 인터페이스
↓
하나의 추상 메서드
↓
그 메서드의 동작을 구현
↓
익명 클래스로 표현 가능
↓
람다식으로 더 간결하게 표현 가능
↓
다른 메서드의 인자로 전달 가능
↓
필요한 시점에 실행
결국 람다를 처음 볼 때부터 어려운 문법으로 생각할 필요는 없었다.
(int n) -> n + 9는 int 값을 하나 받아서 9를 더한 값을 반환하는 동작이다.
그리고 그 동작이 어떤 메서드에 해당하는지는 람다가 사용되는 함수형 인터페이스를 확인하면 알 수 있다.
이렇게 생각하니 Runnable, 익명 클래스, 람다식이 따로 떨어진 문법이 아니라 “실행할 동작을 객체처럼 전달한다”는 하나의 흐름으로 연결되기 시작했다.