◆ ESSAY
matches 를 구현하지 않았는데 왜 동작하나
RowFilter 인지는 어떻게 아나
@FunctionalInterface 는 필수가 아니다Object 의 메서드는 세지 않는다리팩토링을 하다 프로젝트 코드에서 이런 걸 만났다.
@FunctionalInterface
private interface RowFilter {
boolean matches(Row row);
}
matches 를 구현한 클래스는 코드 어디에도 없다. 그런데 호출부는 이렇게 생겼다.
return nearest(distanceM, row -> row.name() != null);
구현하지도 않은 matches 가 어떻게 호출되는가. 이 질문 하나를 컴파일러와 바이트코드까지
내려가며 확인한 기록이다.
거리축 위 어떤 지점(예: 케이블 선로의 1,250m 지점)에 사고가 났을 때, 그 위치를 사람이 알아볼 수 있게 설명해야 한다. 기준점 목록에서 가장 가까운 것을 골라 "○○에서 약 199m" 라고 안내하는 식이다.
기준점을 고르는 규칙은 여러 가지다. 이름, 접속 설비명, 주소 유무를 각각 검사한다. 가장 가까운 것 찾기 알고리즘은 하나인데 후보 조건만 다르다. 그래서 코드가 이렇게 생겼다.
public final class NearestIndex {
private final List<Row> rows; // 거리 오름차순으로 정렬돼 있다
/** 이름이 있는 가장 가까운 행 */
private Row nearestNamed(double distanceM) {
return nearest(distanceM, row -> row.name() != null);
}
/** 설비명이 비어 있지 않은 가장 가까운 행 */
private Row nearestFacility(double distanceM) {
return nearest(distanceM, row -> row.facility() != null && !row.facility().isBlank());
}
/** 조건에 맞는 행 중 거리 차가 가장 작은 것 */
private Row nearest(double distanceM, RowFilter filter) {
Row best = null;
double bestGap = Double.MAX_VALUE;
for (Row row : rows) {
if (!filter.matches(row)) continue; // ← 여기서 true/false 로 거른다
double gap = Math.abs(row.distanceM() - distanceM);
if (gap < bestGap) { best = row; bestGap = gap; }
}
return best;
}
@FunctionalInterface
private interface RowFilter {
boolean matches(Row row);
}
}
filter.matches(row) 가 true 면 후보로 두고 false 면 건너뛴다. 반환형이 boolean 이니
필터의 동작은 그것뿐이다. 무엇을 후보로 볼지만 주입하고 탐색 로직은 한 벌만 유지하는
구조다. 전략 패턴의 최소 형태에 가깝다.
matches 를 구현하지 않았는데 왜 동작하나컴파일러는 추상 메서드 후보가 하나뿐이므로 람다를 그 메서드의 구현으로 확정한다.
matches라는 이름을 보고 추측하는 과정은 없다.
nearest(...) 의 두 번째 파라미터 타입이 RowFilter 임을 읽는다RowFilter 의 추상 메서드를 센다 → 딱 하나(Row) → boolean 과 맞는지 검사한다이 유일한 추상 메서드를 함수 서술자(function descriptor) 라고 한다. 이름은 검사에 쓰이지 않는다.
@FunctionalInterface interface RowFilter { boolean matches(Row row); }
interface 이름이달라도된다 { boolean 아무거나(Row row); }
static Row first(List<Row> rows, RowFilter f) {
for (Row r : rows) if (f.matches(r)) return r; // matches 로 호출
return null;
}
static Row first2(List<Row> rows, 이름이달라도된다 f) {
for (Row r : rows) if (f.아무거나(r)) return r; // 아무거나 로 호출
return null;
}
matches 로 순회 : Row[name=A지점]
아무거나 로 순회 : Row[name=A지점]
같은 람다 row -> row.name() != null 인데 인터페이스에 따라 다른 이름으로 호출된다.
@FunctionalInterface
interface RowFilter {
boolean matches(Row row);
boolean somethingElse(Row row); // 하나 더
}
error: Unexpected @FunctionalInterface annotation
RowFilter is not a functional interface
multiple non-overriding abstract methods found in interface RowFilter
error: incompatible types: RowFilter is not a functional interface
어느 메서드를 구현하라는 것인지 알 수 없어 에러가 난다.
RowFilter 인지는 어떻게 아나추론 없이 메서드 선언에 적힌 타입을 읽는다.
// ↓ 여기
private Row nearest(double distanceM, RowFilter filter) { ... }
nearest(distanceM, row -> row.name() != null);
// └─ 2번째 인자 자리 → 타깃 타입 = RowFilter
| 타입을 누가 정하나 | |
|---|---|
일반 값 nearest(150.0, ...) | 값이 타입을 갖고 있고(double) 파라미터와 맞는지 검사 |
람다 row -> ... | 자기 타입이 없다. 파라미터 선언에서 타입을 받아 그 타입이 된다 |
람다는 타입을 제공하지 않고 받는다. 그래서 타깃 타입이 없는 자리에서는 아예 쓸 수 없다.
var f = row -> row != null; // error: cannot infer type for local variable f
Object o = row -> row != null; // error: Object is not a functional interface
static void run(RowFilter f) {}
static void run(OtherFilter f) {} // 후보가 둘
run(row -> true); // error: reference to run is ambiguous
세 번째 예제에서 후보가 둘이 되자 컴파일러는 타깃 타입을 정하지 못한다. 파라미터 선언이 하나로 정해져야 타깃 타입을 알 수 있다. 람다만 보고 알아낼 수는 없다.
row -> row.name() != null
// ↑ row 에 타입을 안 적었는데 Row 의 메서드를 부를 수 있는 이유
타깃 타입 → 함수 서술자 boolean matches(Row row) → 그 파라미터 타입이 람다의 row 에
주입된다. (Row row) -> ... 라고 적어도 되지만 중복이다.
first(rows, "문자열"); // (1)
first(rows, 42); // (2)
first(rows, new Object()); // (3)
first(rows, () -> true); // (4) 파라미터 개수 불일치
first(rows, (String s) -> true); // (5) 파라미터 타입 불일치
first(rows, row -> "문자열"); // (6) 반환형 불일치
first(rows, row -> row.name() != null); // (7) 통과
(1) error: incompatible types: String cannot be converted to RowFilter
(2) error: incompatible types: int cannot be converted to RowFilter
(3) error: incompatible types: Object cannot be converted to RowFilter
(4) error: incompatible types: incompatible parameter types in lambda expression
(5) error: incompatible types: incompatible parameter types in lambda expression
(6) error: bad return type in lambda expression
6 errors
7개 중 6개가 컴파일 에러다. 규칙은 두 갈래다.
| 넘긴 것 | 검사 방식 |
|---|---|
| 이미 타입이 있는 값 | 대입 호환성: 그 값의 타입이 RowFilter 이거나 하위 타입인가 |
| 람다 / 메서드 참조 | 모양 검사: 파라미터 개수·타입·반환형이 함수 서술자와 맞는가 |
double d = "문자열" 이 막히는 것과 RowFilter f = "문자열" 이 막히는 것은 같은 강도다.
@FunctionalInterface interface RowFilter { boolean matches(Row row); }
@FunctionalInterface interface OtherFilter { boolean matches(Row row); } // 완전히 동일
RowFilter a = row -> row.name() != null;
OtherFilter b = a;
error: incompatible types: RowFilter cannot be converted to OtherFilter
자바는 명목적 타이핑(nominal typing) 을 쓴다. 람다 표현식은 아직 고정된
타입이 없으므로 OtherFilter c = row -> ... 처럼 다시 쓰면 된다. 하지만 이미 타입을 가진 값은
못 옮긴다.
@FunctionalInterface 는 필수가 아니다interface NoAnno { boolean 아무거나(Row row); } // 애노테이션 없음
NoAnno c = row -> row.name() != null; // 그래도 된다
추상 메서드가 하나라는 사실만으로 람다를 쓸 수 있다. 애노테이션은 그 사실이 유지되는지 컴파일러에게 검사를 요청하는 표시다. 붙여 두면 메서드를 하나 더 추가한 순간 인터페이스 선언 지점에서 에러가 난다. 없으면 람다 호출부에서만 에러가 나서 원인이 멀리 보인다.
Object 의 메서드는 세지 않는다@FunctionalInterface
interface WithObjectMethods {
boolean matches(Row row);
@Override boolean equals(Object other); // 추상인데도 함수형 자격 유지
@Override String toString();
default boolean not(Row row) { return !matches(row); } // default 는 안 센다
static WithObjectMethods any() { return row -> true; } // static 도 안 센다
}
컴파일된다. 표준 Comparator도 이 구조를 쓴다. 추상 메서드가 compare와 equals
2개인데도 함수형 인터페이스다. equals 는 어차피 Object 가 구현하므로 람다가 채울
필요가 없어 후보에서 빠진다.
여기서 추상 메서드 하나는 람다가 채워야 할 메서드가 하나라는 뜻이다.
javap -p -c 로 뜬 실제 바이트코드다.
private Row nearestNamed(double);
0: aload_0 // this
1: dload_1 // distanceM
2: invokedynamic #0:matches:()RowFilter // ← 필터 인스턴스를 여기서 얻는다
7: invokevirtual nearest:(D,RowFilter)Row
10: areturn
private static boolean lambda$nearestNamed$0(Row); // ← 람다 본문
javap -v 의 BootstrapMethods 는 이렇게 생겼다.
REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(...)CallSite
REF_invokeStatic NearestIndex.lambda$nearestNamed$0:(Row)Z
읽히는 것이 셋이다.
javac로 컴파일하면 람다 본문이 private static 합성 메서드로 나온다
(lambda$nearestNamed$0). 본문이 this를 쓰지 않고 파라미터 row만 보기 때문에
javac가 정적 메서드로 옮길 수 있다.invokedynamic 의 시그니처가 ()RowFilter이고 인자가 없다. 캡처가 없다는
증거다. 외부 변수를 잡으면 (I)RowFilter 처럼 인자가 붙는다.LambdaMetafactory.metafactory(...) 다.| 시점 | 무슨 일 |
|---|---|
| 최초 실행 | invokedynamic 호출 지점을 LambdaMetafactory 가 연결한다. 이 과정에서 RowFilter 구현체를 만드는 방법이 결정된다 |
| 이후 실행 | 캡처가 없는 람다는 같은 인스턴스를 재사용할 수 있다. HotSpot은 보통 재사용하지만 Java 명세가 객체 동일성을 보장하지는 않는다 |
nearest 안 | invokeinterface RowFilter.matches(row)Z → 생성된 클래스가 lambda$nearestNamed$0(row) 로 위임 |
람다 본문이 람다 밖에 선언된 값을 나중에도 사용하도록 함께 보관하는 일을 캡처라고 한다. 람다의 매개변수와 람다 본문 안에서 선언한 변수는 캡처 대상이 아니다.
RowFilter make(double limit) {
String unit = "m";
return row -> {
double distance = row.distanceM(); // distance: 람다 내부 지역변수
System.out.println(unit); // unit: 바깥 지역변수 캡처
return distance > limit; // limit: 메서드 매개변수 캡처
};
}
make(100.0) 호출이 끝나면 원래 스택 프레임의 limit와 unit도 사라진다. 그런데 반환된
RowFilter는 그 뒤에 호출될 수 있다. JVM은 람다가 필요한 100.0과 "m"을 람다 객체가
사용할 수 있는 형태로 넘겨 보관한다. 바이트코드의 호출 지점이
(double, String)RowFilter처럼 보이는 이유다.
지역변수와 메서드 매개변수는 final이거나 effectively final이어야 캡처할 수 있다.
effectively final은 final을 쓰지 않았어도 초기화 후 다른 값을 다시 대입하지 않았다는 뜻이다.
double limit = 100.0;
RowFilter ok = row -> row.distanceM() > limit; // 가능
limit = 200.0;
// 위 람다와 재대입을 함께 두면 컴파일 오류: limit은 effectively final이 아니다.
람다는 생성 시점의 지역변수 값을 가져간다. 이후에 지역변수가 바뀌는 모습을 공유하지는 않는다. 참조형 변수라면 객체 대신 참조값을 복사한다. 참조가 가리키는 객체의 상태는 바꿀 수 있다.
List<String> names = new ArrayList<>();
Runnable r = () -> names.add("A"); // 가능: names에 다른 참조를 재대입하지 않음
names = new ArrayList<>(); // 함께 사용하면 컴파일 오류
필드는 규칙이 다르다. 필드는 해당 객체를 통해 읽는다. 지역변수처럼 복사해서 캡처하지 않는다.
인스턴스 필드를 사용하면 람다는 감싸는 객체인 this에 접근해야 하므로 구현 관점에서는
this를 캡처한 셈이다. 필드 자체에는 effectively final 제한이 없다.
private double limit = 100.0;
RowFilter make() {
return row -> row.distanceM() > this.limit;
}
따라서 make()로 필터를 만든 뒤 limit을 바꾸면 필터도 바뀐 값을 읽는다. 지역변수 캡처와
인스턴스 필드 접근은 구분해야 한다.
static RowFilter make() { return row -> row.name() != null; } // 캡처 없음
make() == make() // HotSpot에서는 흔히 true. 명세상 결과를 전제로 하면 안 된다
캡처가 없는 람다는 실행할 때 함께 저장할 값이 없다. JVM은 이런 람다의 인스턴스를 호출 지점별로 재사용할 수 있고 HotSpot도 보통 그렇게 최적화한다. 반대로
nearest(distanceM, row -> row.distanceM() > 어떤지역변수) // 캡처 있음
는 지역변수를 함께 보관해야 한다. HotSpot에서는 보통 평가할 때 인스턴스가 필요하므로 루프 안의
캡처 람다는 할당 비용 후보가 된다. 다만 JIT가 인라이닝과 탈출 분석으로 할당을 없앨 수도 있다.
람다의 ==, 객체 잠금, System.identityHashCode에 기대지 말고 성능이 중요한 경로는 JMH와
프로파일러로 확인해야 한다.
RowFilter f = row -> row.name() != null;
f.getClass() // class Demo$$Lambda$248/0x00007f3e6c153000
f.getClass().getInterfaces() // [interface Demo$RowFilter]
f.getClass().getSuperclass() // class java.lang.Object
f.getClass().isHidden() // true
f instanceof RowFilter // true
위 결과는 현재 HotSpot에서 관찰한 구현이다. LambdaMetafactory가 RowFilter를 구현하는
hidden class를 만들었지만 Java 언어 명세가 모든 JVM에 이 구현 방식을 강제하는 것은
아니다.
| 일반 클래스 | 람다의 클래스 | |
|---|---|---|
| 클래스 파일 | 디스크에 존재 | 없음 (메모리에서 생성) |
| 이름 | MyFilter | Demo$$Lambda$248/0x00007f3e... |
| 이름으로 탐색 | Class.forName 가능 | 불가능 |
/0x... 접미사가 hidden class 의 표식이다. Class.forName("Demo$$Lambda$248") 은 실패한다.
람다 객체도 자바의 타입 체계 안에 있다. instanceof RowFilter 가 true 이고 new MyFilter() 로 만든 객체와 런타임 지위가 동일하다. 문법만
짧다.
그리고 RowFilter 자체도 특별하지 않다. javap -v 로 뜬 클래스 파일은 이렇다.
interface Demo$RowFilter
flags: (0x0600) ACC_INTERFACE, ACC_ABSTRACT ← 그 이상 없음
public abstract boolean matches(Demo$Row);
RuntimeVisibleAnnotations:
java.lang.FunctionalInterface ← 그냥 애노테이션 한 개
JVM에 함수형 인터페이스라는 개념은 없다. ACC_FUNCTIONAL 같은 플래그도 없다.
평범한 인터페이스의 추상 메서드가 하나일 때 컴파일러가 붙이는 성질일 뿐이다.
같은 동작을 만드는 세 가지를 컴파일·실행해 비교했다.
private Row nearestNamed(double distanceM) {
return nearest(distanceM, new RowFilter() {
@Override
public boolean matches(Row row) {
return row.name() != null;
}
});
}
private static final RowFilter NAMED = new NamedFilter();
private static final class NamedFilter implements RowFilter {
@Override
public boolean matches(Row row) {
return row.name() != null;
}
}
private Row nearestNamed(double distanceM) {
return nearest(distanceM, NAMED);
}
private Row nearestNamed(double distanceM) {
Row best = null;
double bestGap = Double.MAX_VALUE;
for (Row row : rows) {
if (row.name() == null) continue;
double gap = Math.abs(row.distanceM() - distanceM);
if (gap < bestGap) { best = row; bestGap = gap; }
}
return best;
}
// nearestFacility 도 같은 루프를 조건만 바꿔 복사
| 람다 | A 익명 | B 이름 클래스 | C 루프 복사 | |
|---|---|---|---|---|
| 필터 2개 코드 | 11줄 | 20줄 | 24줄 | 22줄 |
| 클래스 파일 | 0개 | 2개 ($1, $2) | 2개 | 0개 |
| 인스턴스 | JVM이 재사용 가능 | 호출마다 새로 | 상수 1개 | 없음 |
| 조건이 보이는 위치 | 쓰는 자리 | 쓰는 자리 | 떨어져 있음 | 쓰는 자리 |
| 조건 하나 추가 비용 | 한 줄 | 한 줄 | 클래스 하나 | 루프 하나 복사 |
| 탐색 로직 | 1벌 | 1벌 | 1벌 | 2벌 |
실제로 컴파일해 보면 클래스 파일이 이렇게 남는다.
Demo$1.class ← A의 익명
Demo$2.class ← 또 다른 익명
Demo$NamedFilter.class ← B
Demo$FacilityFilter.class ← B
Demo$RowFilter.class ← 인터페이스 (어느 방식이든 필요)
람다 두 개는 클래스 파일이 없다.
람다는 구현체를 어딘가에 선언하는 일, 곧 선언 보일러플레이트를 없앴다. nearest 라는
공통 루프는 세 방식 모두 그대로 쓸 수 있으므로 탐색 로직 중복은 람다의 유무와 별개다.
new RowFilter() { ... } 와 row -> ... 는 거의 같지만 셋이 다르다.
익명 클래스는 컴파일 시점에 $1, $2 로 디스크에 만들어진다. 람다는 런타임 hidden class 라
남지 않는다. 익명 클래스를 남발하면 클래스 파일 수가 늘어 로딩 비용이 붙는다.
anonAgain() == anonAgain() // false: new 표현식은 매번 새 객체
lambdaAgain() == lambdaAgain() // 흔히 true지만 Java 명세의 보장은 아님
익명 클래스의 new는 새 인스턴스를 만들도록 명세에 적혀 있다. 람다 평가는 기존 인스턴스를
돌려줄 수도, 새 인스턴스를 만들 수도 있다. 람다를 값 객체처럼 비교하거나 락으로 쓰면 안 된다.
this 의 의미가 다르다private final String tag = "감싸는 객체";
RowFilter viaAnonymous() {
return new RowFilter() {
@Override public boolean matches(Row row) {
System.out.println(this.getClass().getSimpleName()); // "": 익명 클래스 자신
System.out.println(Demo.this.tag); // "감싸는 객체"
return true;
}
};
}
RowFilter viaLambda() {
return row -> {
System.out.println(this.getClass().getSimpleName()); // "Demo": 감싸는 객체
System.out.println(this.tag); // "감싸는 객체"
return true;
};
}
viaAnonymous 실행 결과:
← 첫 줄은 빈 문자열: 익명 클래스에는 단순 이름이 없음
감싸는 객체
viaLambda 실행 결과:
Demo
감싸는 객체
익명 클래스 선언은 실제 객체의 클래스 본문을 만든다. 그 안의 this는 새로 만든 익명
RowFilter 객체다. getSimpleName()이 빈 문자열인 것은 익명 클래스에 단순 이름(simple name)이 없기
때문이다. this는 그대로 있다. 감싸는 Demo 객체를 가리키려면
Demo.this, 그 필드를 읽으려면 Demo.this.tag라고 쓴다.
람다는 새로운 this를 선언하지 않는다. 람다 안의 this와 super, 한정하지 않은 필드·메서드
이름은 람다가 놓인 바깥 문맥과 같은 뜻으로 해석된다. 이를 어휘적 스코핑(lexical scoping)
또는 this에 대해 투명하다고 표현한다.
private final String tag = "감싸는 객체";
RowFilter viaLambda() {
return row -> {
System.out.println(this == Demo.this); // true
System.out.println(tag); // this.tag와 같은 뜻
System.out.println(toString()); // this.toString()과 같은 뜻
return true;
};
}
람다는 스코프를 만들지 않는다고만 외우면 오해가 생긴다. 람다 매개변수와 블록 안의
지역변수에는 각자의 스코프가 있다. 정확히 말하면 람다는 별도의 this 바인딩을 만들지 않는다.
void example() {
int outer = 1;
RowFilter f = row -> {
int inner = 2; // 람다 블록 안에서만 유효
return row.distanceM() > outer + inner;
};
}
정적 메서드 안에서는 바깥 문맥에도 this가 없으므로 람다 안에서 this를 쓰면 컴파일 오류가
난다. 반면 익명 클래스는 정적 메서드 안에서 만들어도 익명 객체 자신의 this를 가진다.
static RowFilter lambdaInStaticMethod() {
return row -> this.tag != null; // 컴파일 오류: static 문맥에는 this가 없음
}
static RowFilter anonymousInStaticMethod() {
return new RowFilter() {
@Override public boolean matches(Row row) {
return this != null; // 가능: this는 익명 RowFilter 객체
}
};
}
javac는 람다 본문을 합성 메서드로 옮길 수 있다. 이 문서에서 확인한 바이트코드에서는 this를
쓰지 않는 본문이 static 합성 메서드가 되었고 this를 쓰는 본문은 인스턴스 합성 메서드가
되었다. 이것은 관찰한 컴파일 결과다. this의 의미를 정하는 언어 규칙은 Java 언어 명세에 있다.
컴파일러는 먼저 정해진 그 의미를 지키는 범위에서 다른 방식으로 구현할 수 있다.
RowFilter 는 추상 메서드가 하나뿐인 평범한 인터페이스다. JVM에 함수형이라는 타입
종류는 없다.String·int·Object·모양이 다른 람다 전부 거부된다.javac 컴파일 결과는 invokedynamic + private static 합성 메서드이고
HotSpot은 런타임에 LambdaMetafactory를 통해 해당 인터페이스를 구현하는 hidden class를
만든다. Java 언어 명세는 이 구현 형태까지 강제하지 않는다.boolean 이니 필터가 하는 일은 true/false 하나다. if (!filter.matches(row)) continue;가 조건 주입을 실행한다.처음의 질문으로 돌아가면 matches 를 구현한 클래스는 있다. 우리가 적지 않았고
컴파일러와 JVM이 대신 적어 줬을 뿐이다.