Groovy 시맨틱(Semantics)
Groovy 시맨틱(Semantics)
이 장에서는 Groovy 프로그래밍 언어의 시맨틱(의미론) 을 다뤄요. 문장(statement)이 어떻게 정의되고, 표현식이 어떻게 평가되며, 타입이 어떻게 추론되고 검사되는지에 대한 규칙들을 하나씩 살펴볼게요.
출처: Groovy 공식 문서
본문
1. 문장(Statements)
1.1. 변수 정의(Variable definition)
변수는 타입(예: String)으로 정의하거나, 키워드 def를 붙여 정의할 수 있어요.
String x
def y
def는 타입 플레이스홀더 역할을 해요. 즉, 명시적인 타입을 주고 싶지 않을 때 타입 이름을 대신해 주는 거죠. 컴파일 시점에 타입이 중요하지 않은 경우, 또는 (Groovy의 정적 특성에 따라) 타입 추론에 의존하고 싶은 경우에 사용해요. 변수 정의에는 반드시 타입이나 플레이스홀더가 있어야 해요. 타입을 생략하면 그 이름이 (앞서 선언된) 기존 변수를 가리키는 것으로 간주돼요. 스크립트에서는 선언되지 않은 변수가 Script 바인딩에서 가져온 것으로 가정되고, 그 외의 경우엔 누락된 프로퍼티(동적 Groovy)나 컴파일 오류(정적 Groovy)가 발생해요. def를 Object의 별명이라고 생각하면 금방 이해할 수 있어요.
변수 정의에는 초기값을 줄 수도 있는데, 이 경우 선언과 할당(다음 절에서 다룰)이 하나로 합쳐진 셈이에요.
리스트나 맵 같은 변수 정의 타입은 제네릭으로 정밀하게 만들 수 있어요. 예:
List<String> names. 제네릭 지원에 대한 자세한 내용은 제네릭 섹션을 참고하세요.
Java 10부터는
var라는 예약 타입이 도입되었어요. 이것도 변수 정의에서def와 비슷하게 타입 플레이스홀더 역할을 해요. 그래서 Java와의 호환을 위해 Groovy도var z처럼var로 변수를 정의할 수 있게 해줘요. 변수 정의의 맥락에서var는def의 별명이라고 생각하면 돼요. Java 코드를 코드베이스에 붙여 넣었거나, 코드베이스의 독자(또는 유지보수자)가 주로 Java에 익숙한 사람들이라서 코드를 익숙하게 보이게 하고 싶다면var를 쓸 수도 있어요.
1.2. 변수 할당(Variable assignment)
나중에 사용하기 위해 변수에 값을 할당할 수 있어요. 직접 실행해 보세요:
x = 1
println x
x = new java.util.Date()
println x
x = -3.1499392
println x
x = false
println x
x = "Hi"
println x
1.2.1. 다중 할당(Multiple assignment)
Groovy는 다중 할당을 지원해요. 즉 여러 변수에 한 번에 값을 할당할 수 있죠:
def (a, b, c) = [10, 20, 'foo']
assert a == 10 && b == 20 && c == 'foo'
원한다면 선언의 일부로 타입을 지정할 수도 있어요:
def (int i, String j) = [10, 'foo']
assert i == 10 && j == 'foo'
변수 선언뿐 아니라 이미 존재하는 변수에도 적용할 수 있어요:
def nums = [1, 3, 5]
def a, b, c
(a, b, c) = nums
assert a == 1 && b == 3 && c == 5
이 문법은 리스트뿐 아니라 배열, 그리고 이 둘을 반환하는 메서드에서도 동작해요:
def (_, month, year) = "18th June 2009".split()
assert "In $month of $year" == 'In June of 2009'
1.2.2. 오버플로와 언더플로(Overflow and Underflow)
왼쪽 변수가 너무 많으면 남는 변수는 null로 채워져요:
def (a, b, c) = [1, 2]
assert a == 1 && b == 2 && c == null
오른쪽 값이 너무 많으면 남는 값은 무시돼요:
def (a, b) = [1, 2, 3]
assert a == 1 && b == 2
1.2.3. 다중 할당으로 객체 구조 분해(Object destructuring)
Groovy 연산자를 다루는 섹션에서 첨자(subscript) 연산자의 경우, getAt()/putAt() 메서드를 어떻게 오버라이드하는지 설명했어요.
이 기법을 이용하면 다중 할당과 첨자 연산자 메서드를 결합해 객체 구조 분해(object destructuring)를 구현할 수 있어요.
불변(immutable) Coordinates 클래스, 즉 위도/경도 double 쌍을 담는 클래스를 생각해 보고, getAt() 메서드 구현을 살펴볼게요:
@Immutable
class Coordinates {
double latitude
double longitude
double getAt(int idx) {
if (idx == 0) latitude
else if (idx == 1) longitude
else throw new Exception('Wrong coordinate index, use 0 or 1')
}
}
이제 이 클래스를 인스턴스화하고 경도와 위도를 구조 분해해 볼게요:
def coordinates = new Coordinates(latitude: 43.23, longitude: 3.67) (1)
def (la, lo) = coordinates (2)
assert la == 43.23 (3)
assert lo == 3.67
(1) — Coordinates 클래스의 인스턴스를 생성했어요
(2) — 그 다음 다중 할당을 사용해 개별 경도/위도 값을 얻었어요
(3) — 마지막으로 그 값들을 검증(assert)했어요
1.3. 제어 구조(Control structures)
1.3.1. 조건 구조(Conditional structures)
if / else
Groovy는 Java의 일반적인 if-else 문법을 지원해요.
def x = false
def y = false
if ( !x ) {
x = true
}
assert x == true
if ( x ) {
x = false
} else {
y = true
}
assert x == y
Groovy는 일반적인 Java의 "중첩" if then else if 문법도 지원해요:
if ( ... ) {
...
} else if (...) {
...
} else {
...
}
switch / case
Groovy의 switch 문은 Java 코드와 하위 호환이 돼요. 그래서 여러 매치가 같은 코드를 공유하도록 case를 통과(fall through)할 수 있죠.
한 가지 차이는 Groovy의 switch 문이 어떤 종류의 switch 값이든 다룰 수 있고, 다양한 종류의 매칭을 수행할 수 있다는 거예요.
def x = 1.23
def result = ""
switch (x) {
case "foo":
result = "found foo"
// lets fall through
case "bar":
result += "bar"
case [4, 5, 6, 'inList']:
result = "list"
break
case 12..30:
result = "range"
break
case Integer:
result = "integer"
break
case Number:
result = "number"
break
case ~/fo*/: // toString() representation of x matches the pattern?
result = "foo regex"
break
case { it < 0 }: // or { x < 0 }
result = "negative"
break
default:
result = "default"
}
assert result == "number"
switch는 다음과 같은 종류의 비교를 지원해요:
- 클래스 case 값: switch 값이 해당 클래스의 인스턴스일 때 매치돼요.
- 정규식 case 값: switch 값의
toString()표현이 정규식과 매치될 때 매치돼요. - 컬렉션 case 값: switch 값이 컬렉션에 포함될 때 매치돼요. 레인지(range)도 리스트이므로 포함돼요.
- 클로저 case 값: 클로저 호출 결과가 Groovy 진리(Groovy truth)에 따라 참일 때 매치돼요.
- 위 어느 것도 해당하지 않으면, case 값이 switch 값과 같을 때 매치돼요.
클로저 case 값을 쓸 때, 기본
it파라미터는 실제로 switch 값(우리 예시에서는 변수x)이에요.
Groovy는 다음 예시처럼 switch 표현식(expression)도 지원해요:
def partner = switch(person) {
case 'Romeo' -> 'Juliet'
case 'Adam' -> 'Eve'
case 'Antony' -> 'Cleopatra'
case 'Bonnie' -> 'Clyde'
}
1.3.2. 반복 구조(Looping structures)
고전적인 for 루프
Groovy는 표준 Java/C for 루프를 지원해요:
String message = ''
for (int i = 0; i < 5; i += 1) {
message += 'Hi '
}
assert message == 'Hi Hi Hi Hi Hi '
향상된 고전 Java 스타일 for 루프
쉼표로 구분된 표현식을 갖는 Java 고전 for 루프의 더 정교한 형태도 지원돼요. 예:
def facts = []
def count = 5
for (int fact = 1, i = 1; i <= count; i++, fact *= i) {
facts << fact
}
assert facts == [1, 2, 6, 24, 120]
for 루프와 결합된 다중 할당
Groovy는 1.6부터 다중 할당 문장을 지원했어요:
// multi-assignment with types
def (String x, int y) = ['foo', 42]
assert "$x $y" == 'foo 42'
이제 이들은 for 루프에도 나타날 수 있어요:
// multi-assignment goes loopy
def baNums = []
for (def (String u, int v) = ['bar', 42]; v < 45; u++, v++) {
baNums << "$u $v"
}
assert baNums == ['bar 42', 'bas 43', 'bat 44']
for in 루프
Groovy의 for 루프는 훨씬 단순하고, 어떤 종류의 배열·컬렉션·Map 등과도 함께 동작해요.
// iterate over a range
def x = 0
for ( i in 0..9 ) {
x += i
}
assert x == 45
// iterate over a list
x = 0
for ( i in [0, 1, 2, 3, 4] ) {
x += i
}
assert x == 10
// iterate over an array
x = 0
for ( i in new int[]{0, 1, 2, 3, 4} ) {
x += i
}
assert x == 10
// iterate over a map
def map = [a:1, b:2, c:3]
x = 0
for ( e in map ) {
x += e.value
}
assert x == 6
// iterate over values in a map
x = 0
for ( v in map.values() ) {
x += v
}
assert x == 6
// iterate over the characters in a string
def list = []
for ( c in 'abc' ) {
list.add(c)
}
assert list == ['a', 'b', 'c']
// iterate with index
for ( int i, k in map.keySet() ) {
assert map.get(k) == i + 1
}
Groovy는 콜론을 사용하는 Java 방식
for (char c : text) {}도 지원해요.
while 루프
Groovy는 Java처럼 일반적인 while {…} 루프를 지원해요:
def x = 0
def y = 5
while ( y-- > 0 ) {
x++
}
assert x == 5
do/while 루프
Java의 클래식 do/while 루프도 지원돼요. 예:
// classic Java-style do..while loop
def count = 5
def fact = 1
do {
fact *= count--
} while(count > 1)
assert fact == 120
1.3.3. 예외 처리(Exception handling)
예외 처리는 Java와 동일해요.
1.3.4. try / catch / finally
try-catch-finally, try-catch, 또는 try-finally 블록 묶음을 지정할 수 있어요.
각 블록의 본문에는 중괄호가 반드시 필요해요.
try {
'moo'.toLong() // this will generate an exception
assert false // asserting that this point should never be reached
} catch ( e ) {
assert e in NumberFormatException
}
매칭되는 try 절 다음에 finally 절 안에 코드를 넣어서, try 절의 코드가 예외를 던지든 말든 finally 절의 코드가 항상 실행되게 할 수 있어요:
def z
try {
def i = 7, j = 0
try {
def k = i / j
assert false //never reached due to Exception in previous line
} finally {
z = 'reached here' //always executed even if Exception thrown
}
} catch ( e ) {
assert e in ArithmeticException
assert z == 'reached here'
}
1.3.5. 멀티 캐치(Multi-catch)
멀티 캐치 블록(Groovy 2.0부터)을 사용하면 여러 예외를 하나의 catch 블록에서 잡아 처리할 수 있어요:
try {
/* ... */
} catch ( IOException | NullPointerException e ) {
/* one block to handle 2 exceptions */
}
1.3.6. ARM - try-with-resources
Groovy는 자동 리소스 관리(ARM, Automatic Resource Management)를 위해 Java 7의 try-with-resources 문보다 더 나은 대안을 자주 제공해요. 그 문법은 Groovy로 마이그레이션하면서도 여전히 옛 방식(Java 7 스타일)을 쓰고 싶은 Java 프로그래머를 위해 지원돼요:
class FromResource extends ByteArrayInputStream {
@Override
void close() throws IOException {
super.close()
println "FromResource closing"
}
FromResource(String input) {
super(input.toLowerCase().bytes)
}
}
class ToResource extends ByteArrayOutputStream {
@Override
void close() throws IOException {
super.close()
println "ToResource closing"
}
}
def wrestle(s) {
try (
FromResource from = new FromResource(s)
ToResource to = new ToResource()
) {
to << from
return to.toString()
}
}
def wrestle2(s) {
FromResource from = new FromResource(s)
try (from; ToResource to = new ToResource()) { // Enhanced try-with-resources in Java 9+
to << from
return to.toString()
}
}
assert wrestle("ARM was here!").contains('arm')
assert wrestle2("ARM was here!").contains('arm')
그러면 다음과 같은 출력이 나와요:
ToResource closing
FromResource closing
ToResource closing
FromResource closing
1.4. 파워 어서션(Power assertion)
Java와 Groovy가 assert 키워드를 공유하지만, Groovy에서의 assert는 아주 다르게 동작해요. 우선, Groovy의 어서션은 JVM의 -ea 플래그와 무관하게 항상 실행돼요. 그래서 단위 테스트에서 일급 선택지가 돼요. "파워 어서션" 개념은 Groovy assert가 어떻게 동작하는지와 직접 관련이 있어요.
파워 어서션은 3부분으로 분해됩니다:
assert [left expression] == [right expression] : (optional message)
어서션의 결과는 Java에서 얻는 것과 아주 달라요. 어서션이 참이면 아무 일도 일어나지 않아요. 어서션이 거짓이면, 검증되는 표현식의 각 하위 표현식 값의 시각적 표현을 제공해요. 예를 들어:
assert 1+1 == 3
는 다음을 산출해요:
Caught: Assertion failed:
assert 1+1 == 3
| |
2 false
파워 어서션은 표현식이 더 복잡할 때 특히 흥미로워져요. 다음 예시처럼요:
def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == [x,z].sum()
그러면 각 하위 표현식의 값을 출력해요:
assert calc(x,y) == [x,z].sum()
| | | | | | |
15 2 7 | 2 5 7
false
위처럼 예쁘게 출력된 오류 메시지가 마음에 들지 않으면, 어서션의 선택적 메시지 부분을 바꿔 사용자 정의 오류 메시지로 대체할 수 있어요. 예:
def x = 2
def y = 7
def z = 5
def calc = { a,b -> a*b+1 }
assert calc(x,y) == z*z : 'Incorrect computation result'
그러면 다음 오류 메시지가 출력돼요:
Incorrect computation result. Expression: (calc.call(x, y) == (z * z)). Values: z = 5, z = 5
1.5. 라벨 문장(Labeled statements)
모든 문장에 라벨을 붙일 수 있어요. 라벨은 코드의 시맨틱에 영향을 주지 않고, 다음 예시처럼 코드를 읽기 쉽게 만드는 데 사용할 수 있어요:
given:
def x = 1
def y = 2
when:
def z = x+y
then:
assert z == 3
라벨이 붙은 문장의 시맨틱을 바꾸지는 않지만, 다음 예시처럼 라벨을 break 명령의 점프 대상으로 사용할 수는 있어요. 다만 이는 허용되더라도 일반적으로 나쁜 코딩 스타일로 간주돼요:
for (int i=0;i<10;i++) {
for (int j=0;j<i;j++) {
println "j=$j"
if (j == 5) {
break exit
}
}
exit: println "i=$i"
}
기본적으로 라벨은 코드의 시맨틱에 영향을 주지 않지만, 라벨은 추상 구문 트리(AST)에 속한다는 점을 이해하는 게 중요해요. 따라서 AST 변환(transformation)이 그 정보를 사용해 코드에 변환을 수행하고, 결과적으로 다른 시맨틱을 유도할 수 있어요. 이것이 특히 Spock Framework가 테스트를 쉽게 만들기 위해 하는 일이에요.
2. 표현식(Expressions)
표현식은 기존 값을 참조하고 새 값을 만들기 위해 코드를 실행하는 데 사용되는 Groovy 프로그램의 구성 요소예요.
Groovy는 Java와 같은 종류의 많은 표현식을 지원해요:
| 예시 표현식 | 설명 |
|---|---|
foo |
변수, 필드, 파라미터 등의 이름 |
this, super, it |
특별한 이름들 |
true, 10, "bar" |
리터럴 |
String.class |
클래스 리터럴 |
( expression ) |
괄호로 감싼 표현식 |
foo++, ~bar |
단항 연산자 표현식 |
foo + bar, bar * baz |
이항 연산자 표현식 |
foo ? bar : baz |
삼항 연산자 표현식 |
(Integer x, Integer y) -> x + y |
람다 표현식 |
assert 'bar' == switch('foo') { case 'foo' -> 'bar' } |
switch 표현식 |
Groovy만의 특별한 표현식도 있어요:
| 예시 표현식 | 설명 |
|---|---|
String |
축약된 클래스 리터럴(모호하지 않을 때) |
{ x, y -> x + y } |
클로저 표현식 |
[1, 3, 5] |
리터럴 리스트 표현식 |
[a:2, b:4, c:6] |
리터럴 맵 표현식 |
Groovy는 Java에서 멤버 접근에 사용되는 일반적인 점 표기법(dot-notation)을 확장해요. Groovy는 관심 있는 데이터의 계층에서 경로를 지정해 계층적 데이터 구조에 접근할 수 있는 특별한 지원을 제공해요. 이러한 Groovy 경로 표현식을 GPath 표현식이라고 불러요.
2.1. GPath 표현식
GPath는 Groovy에 통합된 경로 표현식 언어로, 중첩된 구조적 데이터의 일부를 식별할 수 있게 해줘요. 이런 점에서 XML에 대한 XPath와 유사한 목표와 범위를 가져요. GPath는 종종 XML 처리 맥락에서 쓰이지만, 실제로는 어떤 객체 그래프에도 적용돼요. XPath가 슬래시 /로 구분된 파일시스템 같은 경로 표기법, 트리 계층을 사용하는 반면, GPath는 점-객체 표기법(dot-object notation)을 사용해 객체 탐색을 수행해요.
예를 들어, 관심 대상인 객체나 요소에 대한 경로를 지정할 수 있어요:
a.b.c→ XML에서, a 안의 b 안에 있는 모든 c 요소를 산출해요.a.b.c→ POJO에서, a의 모든 b 프로퍼티에 대한 c 프로퍼티를 산출해요 (JavaBeans의a.getB().getC()같은 느낌).
두 경우 모두 GPath 표현식은 객체 그래프에 대한 질의(query)로 볼 수 있어요. POJO의 경우 객체 그래프는 대부분 작성 중인 프로그램이 객체 인스턴스화와 합성을 통해 만든 것이고, XML 처리의 경우 객체 그래프는 XML 텍스트를 파싱한 결과이며, 대부분 XmlParser나 XmlSlurper 같은 클래스로 만든 것이에요. XML 처리에 대한 더 깊은 내용은 Processing XML을 참고하세요.
XmlParser 또는 XmlSlurper에서 생성된 객체 그래프를 질의할 때, GPath 표현식은
@표기법으로 요소에 정의된 속성을 참조할 수 있어요:a["@href"]→ 맵 같은 표기법: 모든 a 요소의 href 속성,a.'@href'→ 프로퍼티 표기법: 이것을 표현하는 대안,a.@href→ 직접 표기법: 또 다른 대안.
2.1.1. 객체 탐색(Object navigation)
객체 그래프(java reflection으로 얻은 것)에 대한 GPath 표현식의 예를 볼게요. aMethodFoo라는 다른 메서드를 가진 클래스의 비정적(non-static) 메서드 안에 있다고 가정해 봐요:
void aMethodFoo() { println "This is aMethodFoo." } (0)
다음 GPath 표현식은 그 메서드의 이름을 가져와요:
assert ['aMethodFoo'] == this.class.methods.name.grep(~/.*Foo/)
더 정확히는, 위 GPath 표현식은 String 리스트를 생성하는데, 각각은 this에 존재하는 메서드 이름 중 Foo로 끝나는 것이에요.
이제 같은 클래스에 다음 메서드들도 정의되어 있다고 해볼게요:
void aMethodBar() { println "This is aMethodBar." } (1)
void anotherFooMethod() { println "This is anotherFooMethod." } (2)
void aSecondMethodBar() { println "This is aSecondMethodBar." } (3)
그러면 다음 GPath 표현식은 **(1)**과 **(3)**의 이름을 가져오고, **(2)**나 **(0)**은 가져오지 않아요:
assert ['aMethodBar', 'aSecondMethodBar'] as Set == this.class.methods.name.grep(~/.*Bar/) as Set
2.1.2. 표현식 분해(Expression Deconstruction)
this.class.methods.name.grep(~/.*Bar/) 표현식을 분해하면 GPath가 어떻게 평가되는지 알 수 있어요.
this.class
프로퍼티 접근자로, Java의 this.getClass()와 동등하며 Class 객체를 산출해요.
this.class.methods
프로퍼티 접근자로, Java의 this.getClass().getMethods()와 동등하며 Method 객체 배열을 산출해요.
this.class.methods.name
배열의 각 요소에 프로퍼티 접근자를 적용하고 결과 리스트를 생성해요.
this.class.methods.name.grep(…)
this.class.methods.name이 산출한 리스트의 각 요소에 메서드 grep을 호출하고 결과 리스트를 생성해요.
this.class.methods같은 하위 표현식은 배열을 산출하는데, 이는 Java에서this.getClass().getMethods()를 호출하면 생성되는 것과 같기 때문이에요. GPath 표현식에는s가 리스트를 의미한다는 식의 관례가 없어요.
GPath 표현식의 강력한 기능 중 하나는 컬렉션에 대한 프로퍼티 접근이 컬렉션 각 요소에 대한 프로퍼티 접근으로 변환되고 그 결과가 컬렉션으로 모인다는 점이에요. 따라서 this.class.methods.name 표현식은 Java에서 다음과 같이 표현할 수 있어요:
List<String> methodNames = new ArrayList<String>();
for (Method method : this.getClass().getMethods()) {
methodNames.add(method.getName());
}
return methodNames;
컬렉션이 있는 GPath 표현식에서는 배열 접근 표기법도 사용할 수 있어요:
assert 'aSecondMethodBar' == this.class.methods.name.grep(~/.*Bar/).sort()[1]
GPath 표현식에서 배열 접근은 0 기반(zero-based)이에요.
2.1.3. XML 탐색을 위한 GPath
XML 문서와 다양한 형태의 GPath 표현식의 예시를 볼게요:
def xmlText = """
| <root>
| <level>
| <sublevel id='1'>
| <keyVal>
| <key>mykey</key>
| <value>value 123</value>
| </keyVal>
| </sublevel>
| <sublevel id='2'>
| <keyVal>
| <key>anotherKey</key>
| <value>42</value>
| </keyVal>
| <keyVal>
| <key>mykey</key>
| <value>fizzbuzz</value>
| </keyVal>
| </sublevel>
| </level>
| </root>
"""
def root = new XmlSlurper().parseText(xmlText.stripMargin())
assert root.level.size() == 1 (1)
assert root.level.sublevel.size() == 2 (2)
assert root.level.sublevel.findAll { it.@id == 1 }.size() == 1 (3)
assert root.level.sublevel[1].keyVal[0].key.text() == 'anotherKey' (4)
(1) — root 아래에 level 노드가 하나 있어요
(2) — root/level 아래에 sublevel 노드가 두 개 있어요
(3) — 값이 1인 속성 id를 가진 sublevel 요소가 하나 있어요
(4) — root/level 아래 두 번째 sublevel 요소의 첫 번째 keyVal 요소의 key 요소 텍스트 값은 'anotherKey'예요
XML에 대한 GPath 표현식의 더 자세한 내용은 XML 사용자 가이드를 참고하세요.
3. 승격과 강제 변환(Promotion and coercion)
3.1. 숫자 승격(Number promotion)
숫자 승격 규칙은 수학 연산 섹션에 명시되어 있어요.
3.2. 클로저에서 타입으로의 강제 변환
3.2.1. 클로저를 SAM 타입에 할당하기
SAM 타입은 단일 추상 메서드(single abstract method)를 정의하는 타입이에요. 여기에는 다음이 포함돼요:
interface Predicate<T> {
boolean accept(T obj)
}
abstract class Greeter {
abstract String getName()
void greet() {
println "Hello, $name"
}
}
as 연산자를 사용해 어떤 클로저든 SAM 타입으로 변환할 수 있어요:
Predicate filter = { it.contains 'G' } as Predicate
assert filter.accept('Groovy') == true
Greeter greeter = { 'Groovy' } as Greeter
greeter.greet()
하지만 as Type 표현식은 Groovy 2.2.0부터 선택적이에요. 생략하고 그냥 다음과 같이 쓸 수 있어요:
Predicate filter = { it.contains 'G' }
assert filter.accept('Groovy') == true
Greeter greeter = { 'Groovy' }
greeter.greet()
즉, 다음 예시처럼 메서드 포인터도 사용할 수 있어요:
boolean doFilter(String s) { s.contains('G') }
Predicate filter = this.&doFilter
assert filter.accept('Groovy') == true
Greeter greeter = GroovySystem.&getVersion
greeter.greet()
3.2.2. 클로저로 SAM 타입을 받는 메서드 호출하기
클로저→SAM 타입 강제 변환의 두 번째이자 아마 더 중요한 사용 사례는 SAM 타입을 받는 메서드를 호출하는 것이에요. 다음 메서드를 상상해 볼게요:
public <T> List<T> filter(List<T> source, Predicate<T> predicate) {
source.findAll { predicate.accept(it) }
}
그러면 인터페이스의 명시적 구현을 만들 필요 없이 클로저로 호출할 수 있어요:
assert filter(['Java','Groovy'], { it.contains 'G'} as Predicate) == ['Groovy']
하지만 Groovy 2.2.0부터는 명시적 강제 변환을 생략하고 마치 클로저를 쓰는 것처럼 메서드를 호출할 수도 있어요:
assert filter(['Java','Groovy']) { it.contains 'G'} == ['Groovy']
보시다시피 이 장점은 메서드 호출에 클로저 문법을 사용할 수 있다는 것, 즉 클로저를 괄호 밖에 둘 수 있다는 것이고, 코드의 가독성을 높여줘요.
3.2.3. 클로저에서 임의 타입으로의 강제 변환
SAM 타입 외에도 클로저는 어떤 타입으로든, 특히 인터페이스로 강제 변환될 수 있어요. 다음 인터페이스를 정의해 볼게요:
interface FooBar {
int foo()
void bar()
}
as 키워드를 사용해 클로저를 인터페이스로 강제 변환할 수 있어요:
def impl = { println 'ok'; 123 } as FooBar
그러면 모든 메서드가 클로저로 구현된 클래스를 생성해요:
assert impl.foo() == 123
impl.bar()
하지만 클로저를 어떤 클래스로든 강제 변환하는 것도 가능해요. 예를 들어, 어서션을 바꾸지 않고 위에서 정의한 interface를 class로 바꿀 수 있어요:
class FooBar {
int foo() { 1 }
void bar() { println 'bar' }
}
def impl = { println 'ok'; 123 } as FooBar
assert impl.foo() == 123
impl.bar()
3.3. 맵에서 타입으로의 강제 변환
보통 여러 메서드를 가진 인터페이스나 클래스를 구현하기 위해 단일 클로저를 쓰는 것은 바람직하지 않아요. 대안으로, Groovy는 맵을 인터페이스나 클래스로 강제 변환할 수 있게 해줘요. 이 경우 맵의 키는 메서드 이름으로 해석되고, 값은 메서드 구현이에요. 다음 예시는 맵을 Iterator로 강제 변환하는 것을 보여줘요:
def map
map = [
i: 10,
hasNext: { map.i > 0 },
next: { map.i-- },
]
def iter = map as Iterator
물론 다소 인위적인 예시지만 개념을 보여줘요. 실제로 호출되는 메서드만 구현하면 돼요. 하지만 맵에 존재하지 않는 메서드를 호출하면, 호출에 전달된 인자에 따라 MissingMethodException 또는 UnsupportedOperationException이 던져져요. 다음 예시처럼요:
interface X {
void f()
void g(int n)
void h(String s, int n)
}
x = [ f: {println "f called"} ] as X
x.f() // method exists
x.g() // MissingMethodException here
x.g(5) // UnsupportedOperationException here
예외 타입은 호출 자체에 따라 달라져요:
- MissingMethodException: 호출의 인자가 인터페이스/클래스의 인자와 일치하지 않을 때.
- UnsupportedOperationException: 호출의 인자가 인터페이스/클래스의 오버로드된 메서드 중 하나와 일치할 때.
3.4. 문자열에서 enum으로의 강제 변환
Groovy는 String(또는 GString)을 enum 값으로 투명하게 강제 변환할 수 있게 해줘요. 다음 enum을 정의한다고 가정해 볼게요:
enum State {
up,
down
}
그러면 명시적인 as 강제 변환 없이 문자열을 enum에 할당할 수 있어요:
State st = 'up'
assert st == State.up
값으로 GString을 사용하는 것도 가능해요:
def val = "up"
State st = "${val}"
assert st == State.up
하지만 다음은 런타임 오류(IllegalArgumentException)를 던져요:
State st = 'not an enum value'
switch 문에서 암시적 강제 변환을 사용하는 것도 가능하다는 점에 주목하세요:
State switchState(State st) {
switch (st) {
case 'up':
return State.down // explicit constant
case 'down':
return 'up' // implicit coercion for return types
}
}
특히 case가 문자열 상수를 사용하는 방식을 주목하세요. 하지만 enum을 String 인자로 사용하는 메서드를 호출한다면 여전히 명시적인 as 강제 변환을 사용해야 해요:
assert switchState('up' as State) == State.down
assert switchState(State.down) == State.up
3.5. 사용자 정의 타입 강제 변환
클래스는 asType 메서드를 구현해 사용자 정의 강제 변환 전략을 정의할 수 있어요. 사용자 정의 강제 변환은 as 연산자로 호출되며 절대 암시적이지 않아요. 예를 들어 다음과 같이 Polar와 Cartesian 두 클래스를 정의했다고 해볼게요:
class Polar {
double r
double phi
}
class Cartesian {
double x
double y
}
그리고 극좌표(polar)를 직교좌표(cartesian)로 변환하고 싶다고 해봐요. 한 가지 방법은 Polar 클래스에 asType 메서드를 정의하는 것이에요:
def asType(Class target) {
if (Cartesian==target) {
return new Cartesian(x: r*cos(phi), y: r*sin(phi))
}
}
그러면 as 강제 변환 연산자를 사용할 수 있어요:
def sigma = 1E-16
def polar = new Polar(r:1.0,phi:PI/2)
def cartesian = polar as Cartesian
assert abs(cartesian.x-sigma) < sigma
정리하면 Polar 클래스는 다음과 같아요:
class Polar {
double r
double phi
def asType(Class target) {
if (Cartesian==target) {
return new Cartesian(x: r*cos(phi), y: r*sin(phi))
}
}
}
하지만 Polar 클래스 밖에서 asType을 정의하는 것도 가능해요. "폐쇄된" 클래스나 소스 코드를 소유하지 않은 클래스(예: metaclass 사용)에 사용자 정의 강제 변환 전략을 정의하고 싶을 때 실용적이에요:
Polar.metaClass.asType = { Class target ->
if (Cartesian==target) {
return new Cartesian(x: r*cos(phi), y: r*sin(phi))
}
}
3.6. 클래스 리터럴 vs 변수와 as 연산자
as 키워드는 다음과 같이 클래스에 대한 정적 참조가 있을 때만 사용할 수 있어요:
interface Greeter {
void greet()
}
def greeter = { println 'Hello, Groovy!' } as Greeter // Greeter is known statically
greeter.greet()
하지만 Class.forName 호출처럼 리플렉션으로 클래스를 얻는다면 어떨까요?
Class clazz = Class.forName('Greeter')
as 키워드로 클래스에 대한 참조를 사용하려 하면 실패해요:
greeter = { println 'Hello, Groovy!' } as clazz
// throws:
// unable to resolve class clazz
// @ line 9, column 40.
// greeter = { println 'Hello, Groovy!' } as clazz
as 키워드가 클래스 리터럴에서만 동작하기 때문에 실패해요. 대신 asType 메서드를 호출해야 해요:
greeter = { println 'Hello, Groovy!' }.asType(clazz)
greeter.greet()
4. 선택성(Optionality)
4.1. 선택적 괄호
메서드 호출은 인자가 하나 이상 있고 모호함이 없으면 괄호를 생략할 수 있어요:
println 'Hello World'
def maximum = Math.max 5, 10
인자가 없거나 모호한 메서드 호출에는 괄호가 필요해요:
println()
println(Math.max(5, 10))
4.2. 선택적 세미콜론
Groovy에서 줄이 단일 문장만 포함한다면 줄 끝의 세미콜론을 생략할 수 있어요.
즉, 다음과 같은:
assert true;
을 더 관용적으로 이렇게 쓸 수 있어요:
assert true
한 줄에 여러 문장이 있으면 세미콜론으로 구분해야 해요:
boolean a = true; assert a
4.3. 선택적 return 키워드
Groovy에서는 메서드나 클로저 본문에서 마지막으로 평가된 표현식이 반환돼요. 즉 return 키워드는 선택적이라는 뜻이에요.
int add(int a, int b) {
return a+b
}
assert add(1, 2) == 3
은 다음과 같이 짧게 쓸 수 있어요:
int add(int a, int b) {
a+b
}
assert add(1, 2) == 3
4.4. 선택적 public 키워드
기본적으로 Groovy 클래스와 메서드는 public이에요. 따라서 이 클래스는:
public class Server {
public String toString() { "a server" }
}
이 클래스와 동일해요:
class Server {
String toString() { "a server" }
}
5. Groovy 진리(The Groovy Truth)
Groovy는 아래 주어진 규칙을 적용해 표현식이 참인지 거짓인지 결정해요.
5.1. Boolean 표현식
상응하는 Boolean 값이 true면 참이에요.
assert true
assert !false
5.2. 컬렉션과 배열
비어 있지 않은 컬렉션과 배열은 참이에요.
assert [1, 2, 3]
assert ![]
5.3. 매처(Matcher)
Matcher에 매치가 하나 이상 있으면 참이에요.
assert ('a' =~ /a/)
assert !('a' =~ /b/)
5.4. 반복자와 Enumeration
더 많은 요소가 있는 반복자(Iterator)와 Enumeration은 참으로 강제 변환돼요.
assert [0].iterator()
assert ![].iterator()
Vector v = [0] as Vector
Enumeration enumeration = v.elements()
assert enumeration
enumeration.nextElement()
assert !enumeration
5.5. 맵(Map)
비어 있지 않은 맵은 참으로 평가돼요.
assert ['one' : 1]
assert ![:]
5.6. 문자열
비어 있지 않은 String, GString, CharSequence는 참으로 강제 변환돼요.
assert 'a'
assert !''
def nonEmpty = 'a'
assert "$nonEmpty"
def empty = ''
assert !"$empty"
5.7. 숫자
0이 아닌 숫자는 참이에요.
assert 1
assert 3.5
assert !0
5.8. 객체 참조
null이 아닌 객체 참조는 참으로 강제 변환돼요.
assert new Object()
assert !null
5.9. asBoolean() 메서드로 진리 커스터마이징
Groovy가 객체를 true로 평가할지 false로 평가할지 커스터마이징하려면 asBoolean() 메서드를 구현하세요:
class Color {
String name
boolean asBoolean(){
name == 'green' ? true : false
}
}
Groovy는 이 메서드를 호출해 객체를 boolean 값으로 강제 변환해요. 예:
assert new Color(name: 'green')
assert !new Color(name: 'red')
6. 타이핑(Typing)
6.1. 선택적 타이핑(Optional typing)
선택적 타이핑은 변수에 명시적 타입을 넣지 않아도 프로그램이 동작할 수 있다는 개념이에요. 동적 언어인 Groovy는 자연스럽게 그 기능을 구현해요. 예를 들어 변수를 선언할 때:
String aString = 'foo' (1)
assert aString.toUpperCase() (2)
(1) — foo는 명시적 타입 String으로 선언되었어요
(2) — String에 대해 toUpperCase 메서드를 호출할 수 있어요
Groovy는 대신 이렇게 쓰게 해줘요:
def aString = 'foo' (1)
assert aString.toUpperCase() (2)
(1) — foo는 def로 선언되었어요
(2) — aString의 타입이 런타임에 결정되므로 여전히 toUpperCase 메서드를 호출할 수 있어요
그래서 여기서 명시적 타입을 쓰는지는 중요하지 않아요. 특히 이 기능을 정적 타입 검사와 결합하면 타입 검사기가 타입 추론을 수행하므로 흥미로워져요.
마찬가지로 Groovy는 메서드의 파라미터 타입 선언을 의무화하지 않아요:
String concat(String a, String b) {
a+b
}
assert concat('foo','bar') == 'foobar'
은 덕 타이핑(duck typing)의 장점을 활용하기 위해 def를 반환 타입과 파라미터 타입 모두로 사용해 다시 쓸 수 있어요:
def concat(def a, def b) { (1)
a+b
}
assert concat('foo','bar') == 'foobar' (2)
assert concat(1,2) == 3 (3)
(1) — 반환 타입과 파라미터 타입 모두 def를 사용해요
(2) — String으로 메서드를 사용할 수 있게 해줘요
(3) — plus 메서드가 정의되어 있으므로 int로도 사용할 수 있어요
여기서
def키워드를 사용하는 것은 어떤 타입에서든 동작하도록 의도된 메서드의 의도를 설명할 때 권장돼요. 그러나 기술적으로는Object를 대신 사용해도 결과는 같아요. Groovy에서def는Object사용과 엄밀히 동등해요.
마지막으로 반환 타입과 설명자(descriptor) 모두에서 타입을 아예 제거할 수도 있어요. 하지만 반환 타입에서 제거하려면, 컴파일러가 메서드 선언과 메서드 호출을 구분할 수 있도록 메서드에 명시적 수식어(modifier)를 추가해야 해요:
private concat(a,b) { (1)
a+b
}
assert concat('foo','bar') == 'foobar' (2)
assert concat(1,2) == 3 (3)
(1) — 반환 타입을 생략하려면 명시적 수식어를 설정해야 해요.
(2) — String으로 메서드를 사용하는 것은 여전히 가능해요.
(3) — int로도 가능해요.
공개 API의 메서드 파라미터나 반환 타입에서 타입을 생략하는 것은 일반적으로 나쁜 관행으로 간주돼요. 지역 변수에
def를 쓰는 것은 변수의 가시성이 메서드 자체로 제한되므로 별 문제가 아니지만, 메서드 파라미터에 설정하면def가 메서드 시그니처에서Object로 변환되어 사용자가 인자의 기대 타입이 무엇인지 알기 어려워집니다. 따라서 이는 덕 타이핑에 명시적으로 의존하는 경우로 제한해야 해요.
6.2. 정적 타입 검사(Static type checking)
기본적으로 Groovy는 컴파일 시점에 최소한의 타입 검사를 수행해요. Groovy는 주로 동적 언어라서, 정적 컴파일러가 보통 수행하는 대부분의 검사가 컴파일 시점에는 불가능해요. 런타임 메타프로그래밍으로 추가된 메서드는 클래스나 객체의 런타임 동작을 변경할 수 있어요. 다음 예시에서 그 이유를 설명할게요:
class Person { (1)
String firstName
String lastName
}
def p = new Person(firstName: 'Raymond', lastName: 'Devos') (2)
assert p.formattedName == 'Raymond Devos' (3)
(1) — Person 클래스는 firstName과 lastName 두 프로퍼티만 정의해요
(2) — Person의 인스턴스를 만들 수 있어요
(3) — formattedName이라는 메서드를 호출할 수 있어요
동적 언어에서는 위 예시 같은 코드가 오류를 던지지 않는 것이 꽤 흔해요. 어떻게 가능할까요? Java에서라면 컴파일 시점에 실패할 텐데요. 하지만 Groovy에서는 컴파일 시점에 실패하지 않고, 올바르게 작성되었다면 런타임에도 실패하지 않아요. 실제로 이걸 런타임에 동작시키려면 한 가지 가능성은 런타임 메타프로그래밍에 의존하는 것이에요. 그래서 Person 클래스 선언 뒤에 다음 줄만 추가하면 충분해요:
Person.metaClass.getFormattedName = { "$delegate.firstName $delegate.lastName" }
즉, 일반적으로 Groovy에서는 선언 타입을 넘어 객체 타입에 대해 어떤 가정도 할 수 없고, 타입을 안다 해도 컴파일 시점에 어떤 메서드가 호출되고 어떤 프로퍼티가 조회될지 결정할 수 없어요. 여기에는 DSL 작성부터 테스트까지 많은 이점이 있으며, 이는 이 매뉴얼의 다른 섹션에서 다룹니다.
하지만 프로그램이 동적 기능에 의존하지 않고 정적 세계(특히 Java 사고방식)에서 왔다면, 이런 "오류"를 컴파일 시점에 잡지 못하는 것이 놀라울 수 있어요. 이전 예시에서 보았듯이 컴파일러는 이것이 오류라고 확신할 수 없어요. 컴파일러가 오류임을 알게 하려면 타입 검사 모드로 전환한다는 것을 명시적으로 지시해야 해요. 이는 클래스나 메서드에 @groovy.transform.TypeChecked를 붙여서 할 수 있어요.
타입 검사가 활성화되면 컴파일러는 훨씬 더 많은 작업을 수행해요:
- 타입 추론이 활성화되어, 예를 들어 지역 변수에
def를 사용해도 타입 검사기가 할당에서 변수 타입을 추론할 수 있어요. - 메서드 호출이 컴파일 시점에 해석되어, 클래스에 선언되지 않은 메서드면 컴파일러가 오류를 던져요.
- 일반적으로 정적 언어에서 익숙한 모든 컴파일 시점 오류가 나타나요: 메서드 없음, 프로퍼티 없음, 메서드 호출의 비호환 타입, 숫자 정밀도 오류 등.
이 섹션에서는 다양한 상황에서 타입 검사기의 동작을 설명하고 코드에 @TypeChecked를 사용할 때의 한계를 설명할게요.
6.2.1. @TypeChecked 어노테이션
컴파일 시점 타입 검사 활성화
groovy.transform.TypeChecked 어노테이션은 타입 검사를 활성화해요. 클래스에 붙일 수 있어요:
@groovy.transform.TypeChecked
class Calculator {
int sum(int x, int y) { x+y }
}
또는 메서드에 붙일 수 있어요:
class Calculator {
@groovy.transform.TypeChecked
int sum(int x, int y) { x+y }
}
첫 번째 경우엔 어노테이션이 붙은 클래스의 모든 메서드·프로퍼티·필드·내부 클래스 등이 타입 검사되고, 두 번째 경우엔 해당 메서드와 그 안에 포함된 잠재적 클로저나 익명 내부 클래스만 타입 검사돼요.
섹션 건너뛰기
타입 검사의 범위를 제한할 수 있어요. 예를 들어 클래스가 타입 검사되면, 메서드에 @TypeChecked(TypeCheckingMode.SKIP)를 붙여 타입 검사기가 그 메서드를 건너뛰도록 지시할 수 있어요:
import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode
@TypeChecked (1)
class GreetingService {
String greeting() { (2)
doGreet()
}
@TypeChecked(TypeCheckingMode.SKIP) (3)
private String doGreet() {
def b = new SentenceBuilder()
b.Hello.my.name.is.John (4)
b
}
}
def s = new GreetingService()
assert s.greeting() == 'Hello my name is John'
(1) — GreetingService 클래스가 타입 검사됨으로 표시돼요
(2) — 그래서 greeting 메서드가 자동으로 타입 검사돼요
(3) — 하지만 doGreet는 SKIP로 표시돼요
(4) — 타입 검사기가 여기서 누락된 프로퍼티에 대해 불평하지 않아요
이전 예시에서 SentenceBuilder는 동적 코드에 의존해요. 실제 Hello 메서드나 프로퍼티가 없어서 타입 검사기가 보통 불평하고 컴파일이 실패해요. 빌더를 사용하는 메서드가 TypeCheckingMode.SKIP로 표시되어 이 메서드에 대해 타입 검사가 건너뛰어져, 클래스의 나머지 부분이 타입 검사되더라도 코드가 컴파일돼요.
다음 섹션들에서는 Groovy 타입 검사의 시맨틱을 설명할게요.
6.2.2. 할당 타입 검사
타입 A의 객체 o는 다음 조건일 때만 타입 T의 변수에 할당할 수 있어요:
- T가 A와 같음
Date now = new Date() - 또는 T가 String, boolean, Boolean, Class 중 하나
String s = new Date() // implicit call to toString Boolean boxed = 'some string' // Groovy truth boolean prim = 'some string' // Groovy truth Class clazz = 'java.lang.String' // class coercion - 또는 o가 null이고 T가 기본 타입이 아님
String s = null // passes int i = null // fails - 또는 T가 배열이고 A가 배열이며 A의 컴포넌트 타입이 T의 컴포넌트 타입에 할당 가능
int[] i = new int[4] // passes int[] i = new String[4] // fails - 또는 T가 배열이고 A가 컬렉션 또는 스트림이며 A의 컴포넌트 타입이 T의 컴포넌트 타입에 할당 가능
int[] i = [1,2,3] // passes int[] i = [1,2, new Date()] // fails Set set = [1,2,3] Number[] na = set // passes def stream = Arrays.stream(1,2,3) int[] i = stream // passes - 또는 T가 A의 수퍼클래스
AbstractList list = new ArrayList() // passes LinkedList list = new ArrayList() // fails - 또는 T가 A가 구현한 인터페이스
List list = new ArrayList() // passes RandomAccess list = new LinkedList() // fails - 또는 T 또는 A가 기본 타입이고 그 박싱 타입이 할당 가능
int i = 0 Integer bi = 1 int x = Integer.valueOf(123) double d = Float.valueOf(5f) - 또는 T가 groovy.lang.Closure를 확장하고 A가 SAM 타입(단일 추상 메서드 타입)
Runnable r = { println 'Hello' } interface SAMType { int doSomething() } SAMType sam = { 123 } assert sam.doSomething() == 123 abstract class AbstractSAM { int calc() { 2* value() } abstract int value() } AbstractSAM c = { 123 } assert c.calc() == 246 - 또는 T와 A가 java.lang.Number에서 파생되고 다음 표에 부합
| T | A | 예시 |
|---|---|---|
| Double | BigDecimal·BigInteger 외 전부 | Double d1 = 4d Double d2 = 4f Double d3 = 4l Double d4 = 4i Double d5 = (short) 4 Double d6 = (byte) 4 |
| Float | BigDecimal·BigInteger·Double 외 전부 | Float f1 = 4f Float f2 = 4l Float f3 = 4i Float f4 = (short) 4 Float f5 = (byte) 4 |
| Long | BigDecimal·BigInteger·Double·Float 외 전부 | Long l1 = 4l Long l2 = 4i Long l3 = (short) 4 Long l4 = (byte) 4 |
| Integer | BigDecimal·BigInteger·Double·Float·Long 외 전부 | Integer i1 = 4i Integer i2 = (short) 4 Integer i3 = (byte) 4 |
| Short | BigDecimal·BigInteger·Double·Float·Long·Integer 외 전부 | Short s1 = (short) 4 Short s2 = (byte) 4 |
| Byte | Byte | Byte b1 = (byte) 4 |
6.2.3. 리스트와 맵 생성자
위 할당 규칙에 더해, 타입 검사 모드에서 할당이 무효로 간주되면, 리스트 리터럴 또는 맵 리터럴 A가 다음 조건일 때 변수 T에 할당될 수 있어요:
- 할당이 변수 선언이고 A가 리스트 리터럴이며 T가 리스트 리터럴의 요소 타입과 일치하는 파라미터를 가진 생성자를 가짐
- 할당이 변수 선언이고 A가 맵 리터럴이며 T가 무인자(no-arg) 생성자와 각 맵 키에 대한 프로퍼티를 가짐
예를 들어, 다음과 같이 쓰는 대신:
@groovy.transform.TupleConstructor
class Person {
String firstName
String lastName
}
Person classic = new Person('Ada','Lovelace')
"리스트 생성자"를 사용할 수 있어요:
Person list = ['Ada','Lovelace']
또는 "맵 생성자"를 사용할 수 있어요:
Person map = [firstName:'Ada', lastName:'Lovelace']
맵 생성자를 사용하면 맵의 키에 대해 같은 이름의 프로퍼티가 정의되어 있는지 추가 검사가 수행돼요. 예를 들어 다음은 컴파일 시점에 실패해요:
@groovy.transform.TupleConstructor
class Person {
String firstName
String lastName
}
Person map = [firstName:'Ada', lastName:'Lovelace', age: 24] (1)
(1) — 타입 검사기가 컴파일 시점에 오류 No such property: age for class: Person을 던져요
6.2.4. 메서드 해석
타입 검사 모드에서는 메서드가 컴파일 시점에 해석돼요. 해석은 이름과 인자로 동작해요. 반환 타입은 메서드 선택에 무관해요. 인자 타입은 다음 규칙에 따라 파라미터 타입과 매칭돼요:
타입 A의 인자 o는 다음 조건일 때만 타입 T의 파라미터에 사용할 수 있어요:
- T가 A와 같음
int sum(int x, int y) { x+y } assert sum(3,4) == 7 - 또는 T가 String이고 A가 GString
String format(String str) { "Result: $str" } assert format("${3+4}") == "Result: 7" - 또는 o가 null이고 T가 기본 타입이 아님
String format(int value) { "Result: $value" } assert format(7) == "Result: 7" format(null) // fails - 또는 T가 배열이고 A가 배열이며 컴포넌트 타입이 할당 가능
String format(String[] values) { "Result: ${values.join(' ')}" } assert format(['a','b'] as String[]) == "Result: a b" format([1,2] as int[]) // fails - 또는 T가 A의 수퍼클래스
String format(AbstractList list) { list.join(',') } format(new ArrayList()) // passes String format(LinkedList list) { list.join(',') } format(new ArrayList()) // fails - 또는 T가 A가 구현한 인터페이스
String format(List list) { list.join(',') } format(new ArrayList()) // passes String format(RandomAccess list) { 'foo' } format(new LinkedList()) // fails - 또는 T 또는 A가 기본 타입이고 박싱 타입이 할당 가능
int sum(int x, Integer y) { x+y } assert sum(3, new Integer(4)) == 7 assert sum(new Integer(3), 4) == 7 assert sum(new Integer(3), new Integer(4)) == 7 assert sum(new Integer(3), 4) == 7 - 또는 T가 groovy.lang.Closure를 확장하고 A가 SAM 타입
interface SAMType { int doSomething() } int twice(SAMType sam) { 2*sam.doSomething() } assert twice { 123 } == 246 abstract class AbstractSAM { int calc() { 2* value() } abstract int value() } int eightTimes(AbstractSAM sam) { 4*sam.calc() } assert eightTimes { 123 } == 984 - 또는 T와 A가 java.lang.Number에서 파생되고 숫자 할당과 같은 규칙에 부합
적절한 이름과 인자를 가진 메서드가 컴파일 시점에 발견되지 않으면 오류가 던져져요. "일반" Groovy와의 차이는 다음 예시에서 확인할 수 있어요:
class MyService {
void doSomething() {
printLine 'Do something' (1)
}
}
(1) — printLine은 오류지만, 동적 모드이므로 컴파일 시점에 오류가 잡히지 않아요
위 예시는 Groovy가 컴파일할 수 있는 클래스를 보여줘요. 하지만 MyService의 인스턴스를 만들고 doSomething 메서드를 호출하려고 하면, printLine이 존재하지 않으므로 런타임에 실패해요. 물론 MethodMissingException을 잡거나 사용자 정의 metaclass를 구현하는 식으로 Groovy가 이 호출을 완벽하게 유효하게 만들 수 있다는 걸 이미 보여줬지만, 그런 경우가 아니라는 걸 안다면 @TypeChecked가 유용해요:
@groovy.transform.TypeChecked
class MyService {
void doSomething() {
printLine 'Do something' (1)
}
}
(1) — printLine은 이번에는 컴파일 타임 오류예요
@TypeChecked를 추가하기만 해도 컴파일 시점 메서드 해석이 촉발돼요. 타입 검사기는 MyService 클래스에서 String을 받는 printLine 메서드를 찾으려 하지만 찾지 못해요. 그러면 다음 메시지로 컴파일이 실패해요:
Cannot find matching method MyService#printLine(java.lang.String)
타입 검사기 뒤에 있는 논리를 이해하는 게 중요해요: 타입 검사기는 컴파일 시점 검사이므로 정의상 어떤 종류의 런타임 메타프로그래밍도 인식하지 못해요. 즉
@TypeChecked없이 완벽히 유효한 코드는 타입 검사가 활성화되면 더 이상 컴파일되지 않을 수 있어요. 이는 특히 덕 타이핑을 생각할 때 그렇습니다:
class Duck {
void quack() { (1)
println 'Quack!'
}
}
class QuackingBird {
void quack() { (2)
println 'Quack!'
}
}
@groovy.transform.TypeChecked
void accept(quacker) {
quacker.quack() (3)
}
accept(new Duck()) (4)
(1) — quack 메서드를 정의하는 Duck 클래스를 정의해요
(2) — quack 메서드도 정의하는 또 다른 QuackingBird 클래스를 정의해요
(3) — quacker는 느슨하게 타입이 지정되어 있고 메서드가 @TypeChecked이므로 컴파일 타임 오류를 얻어요
(4) — 타입 검사되지 않는 Groovy였다면 통과했을 텐데도 말이죠
인터페이스를 도입하는 것 같은 가능한 해결책들이 있어요. 하지만 기본적으로 타입 검사를 활성화하면 타입 안전성을 얻는 대신 언어의 일부 기능을 잃게 돼요. 다행히 Groovy는 플로 타이핑(flow typing) 같은 기능을 도입해 타입 검사된 Groovy와 아닌 Groovy의 차이를 줄여줘요.
6.2.5. 타입 추론(Type inference)
원칙
코드에 @TypeChecked가 붙으면 컴파일러는 타입 추론을 수행해요. 정적 타입에만 의존하지 않고 변수·반환 타입·리터럴 등의 타입을 추론하기 위한 다양한 기법을 사용해서, 타입 검사기를 활성화해도 코드를 가능한 한 깔끔하게 유지해요.
가장 단순한 예는 변수 타입 추론이에요:
def message = 'Welcome to Groovy!' (1)
println message.toUpperCase() (2)
println message.upper() // compile time error (3)
(1) — def 키워드로 변수가 선언되었어요
(2) — toUpperCase 호출은 타입 검사기에 허용돼요
(3) — upper 호출은 컴파일 시점에 실패해요
toUpperCase 호출이 동작하는 이유는 message의 타입이 String으로 추론되었기 때문이에요.
타입 추론에서 변수 vs 필드
컴파일러가 지역 변수에 대해 타입 추론을 수행하지만 필드에 대해서는 어떤 종류의 타입 추론도 수행하지 않고, 항상 필드의 선언 타입으로 돌아간다는 점에 주목할 가치가 있어요. 이를 설명하기 위해 다음 예시를 살펴볼게요:
class SomeClass {
def someUntypedField (1)
String someTypedField (2)
void someMethod() {
someUntypedField = '123' (3)
someUntypedField = someUntypedField.toUpperCase() // compile-time error (4)
}
void someSafeMethod() {
someTypedField = '123' (5)
someTypedField = someTypedField.toUpperCase() (6)
}
void someMethodUsingLocalVariable() {
def localVariable = '123' (7)
someUntypedField = localVariable.toUpperCase() (8)
}
}
(1) — someUntypedField는 선언 타입으로 def를 사용해요
(2) — someTypedField는 선언 타입으로 String을 사용해요
(3) — someUntypedField에 아무거나 할당할 수 있어요
(4) — 하지만 필드가 제대로 타입이 지정되지 않았으므로 toUpperCase 호출은 컴파일 시점에 실패해요
(5) — String 타입 필드에 String을 할당할 수 있어요
(6) — 이번에는 toUpperCase가 허용돼요
(7) — 지역 변수에 String을 할당하면
(8) — 지역 변수에서 toUpperCase 호출이 허용돼요
왜 그런 차이가 있을까요? 이유는 스레드 안전성이에요. 컴파일 시점에 필드의 타입에 대해 어떤 보장도 할 수 없어요. 어떤 스레드든 언제든 어떤 필드에든 접근할 수 있고, 메서드에서 어떤 타입의 변수를 필드에 할당한 순간과 다음 줄에서 사용하는 순간 사이에 다른 스레드가 필드 내용을 바꿨을 수 있어요. 지역 변수는 그렇지 않아요: 변수가 "탈출"하는지 알 수 있으므로 시간에 따라 변수의 타입이 일정한지(아닌지) 확인할 수 있어요. 필드가 final이어도 JVM은 그것에 대해 보장하지 않으므로, 타입 검사기는 필드가 final인지 아닌지에 따라 다르게 동작하지 않아요.
이것이 타입이 지정된 필드를 사용하는 것을 권장하는 이유 중 하나예요. 타입 추론 덕분에 지역 변수에
def를 쓰는 것은 완벽히 괜찮지만 필드는 그렇지 않아요. 필드는 클래스의 공개 API에도 속하므로 타입이 중요합니다.
컬렉션 리터럴 타입 추론
Groovy는 다양한 타입 리터럴 문법을 제공해요. Groovy에는 세 가지 네이티브 컬렉션 리터럴이 있어요:
- 리스트:
[]리터럴 사용 - 맵:
[:]리터럴 사용 - 레인지:
from..to(포함),from..<to(오른쪽 배제),from<..to(왼쪽 배제),from<..<to(완전 배제) 사용
리터럴의 추론 타입은 리터럴의 요소에 따라 달라져요. 다음 표를 보세요:
| 리터럴 | 추론 타입 |
|---|---|
def list = [] |
java.util.List |
def list = ['foo','bar'] |
java.util.List<String> |
def list = ["${foo}","${bar}"] |
java.util.List<GString> — 주의, GString은 String이 아닙니다! |
def map = [:] |
java.util.LinkedHashMap |
def map1 = [someKey: 'someValue'] def map2 = ['someKey': 'someValue'] |
java.util.LinkedHashMap<String,String> |
def map = ["${someKey}": 'someValue'] |
java.util.LinkedHashMap<GString,String> — 주의, 키가 GString입니다! |
def intRange = (0..10) |
groovy.lang.IntRange |
def charRange = ('a'..'z') |
groovy.lang.Range<String> — 경계 타입을 사용해 레인지의 컴포넌트 타입을 추론합니다 |
보시다시피 눈에 띄는 예외인 IntRange를 제외하면, 추론 타입은 컬렉션의 내용을 설명하기 위해 제네릭 타입을 사용해요. 컬렉션이 서로 다른 타입의 요소를 포함하는 경우, 타입 검사기는 여전히 컴포넌트 타입 추론을 수행하지만 least upper bound(최소 상한 결합) 개념을 사용해요.
최소 상한(Least upper bound)
Groovy에서 두 타입 A와 B의 최소 상한(LUB)은 다음을 만족하는 타입으로 정의돼요:
- superclass: A와 B의 공통 수퍼클래스에 해당
- interface: A와 B가 둘 다 구현한 인터페이스에 해당
- A 또는 B가 기본 타입이고 A가 B와 같지 않으면, A와 B의 LUB는 래퍼 타입들의 LUB
A와 B가 공통 인터페이스를 하나만(1) 갖고 공통 수퍼클래스가 Object라면, 둘의 LUB는 공통 인터페이스예요.
최소 상한은 A와 B 둘 다 할당될 수 있는 최소 타입을 나타내요. 예를 들어 A와 B가 둘 다 String이면 둘의 LUB(최소 상한)도 String이에요.
class Top {}
class Bottom1 extends Top {}
class Bottom2 extends Top {}
assert leastUpperBound(String, String) == String (1)
assert leastUpperBound(ArrayList, LinkedList) == AbstractList (2)
assert leastUpperBound(ArrayList, List) == List (3)
assert leastUpperBound(List, List) == List (4)
assert leastUpperBound(Bottom1, Bottom2) == Top (5)
assert leastUpperBound(List, Serializable) == Object (6)
(1) — String과 String의 LUB는 String이에요
(2) — ArrayList와 LinkedList의 LUB는 공통 수퍼타입 AbstractList예요
(3) — ArrayList와 List의 LUB는 유일한 공통 인터페이스 List예요
(4) — 두 동일한 인터페이스의 LUB는 그 인터페이스 자신이에요
(5) — Bottom1과 Bottom2의 LUB는 수퍼클래스 Top이에요
(6) — 공통점이 없는 두 타입의 LUB는 Object예요
이 예시들에서 LUB는 항상 일반적인 JVM 지원 타입으로 표현 가능해요. 하지만 Groovy는 내부적으로 LUB를 더 복잡한 타입으로 표현하는데, 예를 들어 그걸로 변수를 정의하는 데는 사용할 수 없을 수도 있어요. 이를 설명하기 위해 이 예시를 계속해 볼게요:
interface Foo {}
class Top {}
class Bottom extends Top implements Serializable, Foo {}
class SerializableFooImpl implements Serializable, Foo {}
Bottom과 SerializableFooImpl의 최소 상한은 무엇일까요? 공통 수퍼클래스(Object 제외)는 없지만 두 인터페이스(Serializable과 Foo)를 공유해요. 그래서 최소 상한은 두 인터페이스(Serializable과 Foo)의 합집합을 나타내는 타입이에요. 이 타입은 소스 코드에서 정의할 수 없지만 Groovy는 그것을 알아요.
컬렉션 타입 추론(그리고 일반적으로 제네릭 타입 추론) 맥락에서 이는 유용해지는데, 컴포넌트 타입이 최소 상한으로 추론되기 때문이에요. 다음 예시에서 이것이 왜 중요한지 보여줄게요:
interface Greeter { void greet() } (1)
interface Salute { void salute() } (2)
class A implements Greeter, Salute { (3)
void greet() { println "Hello, I'm A!" }
void salute() { println "Bye from A!" }
}
class B implements Greeter, Salute { (4)
void greet() { println "Hello, I'm B!" }
void salute() { println "Bye from B!" }
void exit() { println 'No way!' } (5)
}
def list = [new A(), new B()] (6)
list.each {
it.greet() (7)
it.salute() (8)
it.exit() (9)
}
(1) — Greeter 인터페이스는 단일 메서드 greet를 정의해요
(2) — Salute 인터페이스는 단일 메서드 salute를 정의해요
(3) — 클래스 A는 Greeter와 Salute를 모두 구현하지만 둘을 확장하는 명시적 인터페이스는 없어요
(4) — B도 동일해요
(5) — 하지만 B는 추가 exit 메서드를 정의해요
(6) — list의 타입은 "A와 B의 LUB 리스트"로 추론돼요
(7) — 그래서 Greeter 인터페이스를 통해 A와 B 둘 다에 정의된 greet를 호출할 수 있어요
(8) — Salute 인터페이스를 통해 A와 B 둘 다에 정의된 salute를 호출할 수 있어요
(9) — 하지만 exit 호출은 A와 B의 LUB에 속하지 않으므로(B에만 정의) 컴파일 타임 오류예요
오류 메시지는 다음과 같아요:
[Static type checking] - Cannot find matching method Greeter or Salute#exit()
이는 exit 메서드가 Greeter나 Salute 어느 쪽에도 정의되어 있지 않다는 것, 즉 A와 B의 최소 상한에 정의된 두 인터페이스 어디에도 없다는 것을 나타내요.
instanceof 추론
일반적인, 타입 검사되지 않는 Groovy에서는 다음과 같이 쓸 수 있어요:
class Greeter {
String greeting() { 'Hello' }
}
void doSomething(def o) {
if (o instanceof Greeter) { (1)
println o.greeting() (2)
}
}
doSomething(new Greeter())
(1) — 메서드 호출을 instanceof 검사로 보호해요
(2) — 호출해요
메서드 호출은 동적 디스패치(메서드가 런타임에 선택됨) 때문에 동작해요. Java에서 동등한 코드는 greeting 메서드를 호출하기 전에 o를 Greeter로 캐스팅해야 해요, 왜냐하면 메서드가 컴파일 시점에 선택되기 때문이에요:
if (o instanceof Greeter) {
System.out.println(((Greeter)o).greeting());
}
하지만 Groovy에서는 doSomething 메서드에 @TypeChecked(즉 타입 검사 활성화)를 추가해도 캐스트는 필요하지 않아요. 컴파일러는 캐스트를 선택 사항으로 만드는 instanceof 추론을 내장해요.
플로 타이핑(Flow typing)
플로 타이핑은 타입 검사 모드에서 Groovy의 중요한 개념이며 타입 추론의 확장이에요. 아이디어는 컴파일러가 초기화 시점뿐 아니라 코드의 흐름에서도 변수 타입을 추론할 수 있다는 것이에요:
@groovy.transform.TypeChecked
void flowTyping() {
def o = 'foo' (1)
o = o.toUpperCase() (2)
o = 9d (3)
o = Math.sqrt(o) (4)
}
(1) — 먼저 o가 def로 선언되고 String이 할당돼요
(2) — 컴파일러가 o를 String으로 추론했으므로 toUpperCase 호출이 허용돼요
(3) — o가 double로 재할당돼요
(4) — 컴파일러가 이 시점에 o가 double임을 알므로 Math.sqrt 호출이 컴파일을 통과해요
타입 검사기는 변수의 구체적인 타입이 시간에 따라 달라진다는 사실을 인지해요. 특히 마지막 할당을 다음과 같이 바꾸면:
o = 9d
o = o.toUpperCase()
타입 검사기는 toUpperCase가 호출될 때 o가 double임을 알므로 이번에는 컴파일 시점에 실패해요. 타입 오류라는 거죠.
타입 추론을 촉발하는 것이 def로 변수를 선언한다는 사실이 아니라는 점을 이해하는 게 중요해요. 플로 타이핑은 어떤 타입의 어떤 변수에도 동작해요. 명시적 타입으로 변수를 선언하는 것은 변수에 할당할 수 있는 것을 제약할 뿐이에요:
@groovy.transform.TypeChecked
void flowTypingWithExplicitType() {
List list = ['a','b','c'] (1)
list = list*.toUpperCase() (2)
list = 'foo' (3)
}
(1) — list는 비검사(unchecked) List로 선언되고 문자열 리스트 리터럴이 할당돼요
(2) — 플로 타이핑 덕분에 컴파일을 통과해요: 타입 검사기는 이 시점에 list가 ArrayList<String>임을 알아요
(3) — 하지만 List에 String을 할당할 수는 없으므로 타입 검사 오류예요
또한 변수가 제네릭 정보 없이 선언되어도 타입 검사기가 컴포넌트 타입을 안다는 점을 주목하세요. 따라서 이런 코드는 컴파일이 실패해요:
@groovy.transform.TypeChecked
void flowTypingTypeConstraints1() {
def list = ['a','b','c'] (1)
list.add(1) (2)
}
@groovy.transform.TypeChecked
void flowTypingTypeConstraints2() {
List<?> list = [] (3)
list.addAll(['a','b','c']) (4)
list.add(1) (5)
}
(1) — list는 ArrayList<String>으로 추론돼요
(2) — 그래서 ArrayList<String>에 int를 추가하는 것은 컴파일 타임 오류예요
(3) — list는 List<?>로 선언돼요
(4) — 여기서 list의 추론 타입은 List<capture-of ?>이므로 무언가의 리스트로 addAll을 호출하는 것은 컴파일 타임 오류예요
(5) — 그리고 int로 add를 호출하는 것도 같은 이유로 컴파일 타임 오류예요. add(null)만 허용돼요
이를 고치려면 명시적인 비-와일드카드 타입 인자를 추가해야 해요:
@groovy.transform.TypeChecked
void flowTypingTypeConstraints3() {
List<Serializable> list = [] (1)
list.addAll(['a','b','c']) (2)
list.add(1) (3)
}
(1) — list는 List<Serializable>로 선언되고 빈 리스트로 초기화돼요
(2) — 리스트에 추가된 요소들은 리스트의 선언 타입에 부합해요
(3) — 그리고 정수를 추가하는 것은 허용돼요
플로 타이핑은 클래식 Groovy와 정적 Groovy 사이의 시맨틱 차이를 줄이기 위해 도입되었어요. 특히 Java에서 이 코드의 동작을 생각해 보세요:
public Integer compute(String str) {
return str.length();
}
public String compute(Object o) {
return "Nope";
}
// ...
Object string = "Some string"; (1)
Object result = compute(string); (2)
System.out.println(result); (3)
(1) — o가 Object로 선언되고 String이 할당돼요
(2) — o로 compute 메서드를 호출해요
(3) — 결과를 출력해요
Java에서 이 코드는 Nope를 출력해요, 왜냐하면 메서드 선택이 컴파일 시점에 그리고 선언된 타입을 기반으로 이루어지기 때문이에요. 그래서 런타임에 o가 String이어도 Object 버전이 호출돼요, 왜냐하면 o가 Object로 선언되었기 때문이에요. 요약하자면 Java에서는 변수 타입이든 파라미터 타입이든 반환 타입이든 선언 타입이 가장 중요해요.
Groovy에서는 다음과 같이 쓸 수 있어요:
int compute(String string) { string.length() }
String compute(Object o) { "Nope" }
Object o = 'string'
def result = compute(o)
println result
하지만 이번에는 6을 반환해요, 왜냐하면 실제 인자 타입을 기반으로 런타임에 선택되는 메서드이기 때문이에요. 그래서 런타임에 o는 String이므로 String 변형이 사용돼요. 이 동작은 타입 검사와 무관하다는 점에 주목하세요. Groovy가 일반적으로 동작하는 방식, 즉 동적 디스패치예요.
타입 검사된 Groovy에서는 타입 검사기가 런타임이 선택할 것과 같은 메서드를 컴파일 시점에 선택하도록 하고 싶어요. 언어의 시맨틱 때문에 일반적으로는 불가능하지만, 플로 타이핑으로 더 나아질 수 있어요. 플로 타이핑으로 o는 compute 메서드가 호출될 때 String으로 추론되므로 String을 받고 int를 반환하는 버전이 선택돼요. 즉 메서드의 반환 타입이 String이 아니라 int로 추론될 수 있어요. 이는 후속 호출과 타입 안전성에 중요해요.
그래서 타입 검사된 Groovy에서 플로 타이핑은 매우 중요한 개념이며, @TypeChecked가 적용되면 메서드는 선언 타입이 아니라 인자의 추론 타입을 기반으로 선택된다는 것을 의미해요. 이는 타입 검사기가 잘못된 메서드를 선택할 수도 있으므로 100% 타입 안전성을 보장하지는 않지만, 동적 Groovy에 가장 가까운 시맨틱을 보장해요.
고급 타입 추론
플로 타이핑과 최소 상한 추론의 결합은 여러 상황에서 고급 타입 추론을 수행하고 타입 안전성을 보장하는 데 사용돼요. 특히 프로그램 제어 구조는 변수의 추론 타입을 바꿀 가능성이 높아요:
class Top {
void methodFromTop() {}
}
class Bottom extends Top {
void methodFromBottom() {}
}
def o
if (someCondition) {
o = new Top() (1)
} else {
o = new Bottom() (2)
}
o.methodFromTop() (3)
o.methodFromBottom() // compilation error (4)
(1) — someCondition이 참이면 o에 Top이 할당돼요
(2) — someCondition이 거짓이면 o에 Bottom이 할당돼요
(3) — methodFromTop 호출은 안전해요
(4) — 하지만 methodFromBottom 호출은 안전하지 않으므로 컴파일 타임 오류예요
타입 검사기가 if/else 제어 구조를 방문할 때, if/else 분기에서 할당된 모든 변수를 검사하고 모든 할당의 최소 상한을 계산해요. 이 타입이 if/else 블록 이후 추론된 변수의 타입이에요. 그래서 이 예시에서 o는 if 분기에서 Top이, else 분기에서 Bottom이 할당돼요. 그것들의 LUB는 Top이므로 조건부 분기 이후 컴파일러는 o를 Top으로 추론해요. 따라서 methodFromTop 호출은 허용되지만 methodFromBottom은 허용되지 않아요.
클로저, 특히 클로저 공유 변수(closure shared variable)에도 같은 추론이 존재해요. 클로저 공유 변수는 클로저 밖에서 정의되지만 클로저 안에서 사용되는 변수예요:
def text = 'Hello, world!' (1)
def closure = {
println text (2)
}
(1) — text라는 변수가 선언돼요
(2) — text는 클로저 안에서 사용돼요. 클로저 공유 변수예요.
Groovy는 개발자가 이런 변수를 final일 필요 없이 사용하도록 허용해요. 즉 클로저 공유 변수는 클로저 안에서 재할당될 수 있어요:
String result
doSomething { String it ->
result = "Result: $it"
}
result = result?.toUpperCase()
문제는 클로저가 언제든 실행될(또는 실행되지 않을) 수 있는 독립적인 코드 블록이라는 거예요. 특히 doSomething은 예를 들어 비동기일 수 있어요. 즉 클로저의 본문은 메인 제어 흐름에 속하지 않아요. 그 이유로 타입 검사기는 각 클로저 공유 변수에 대해 변수의 모든 할당의 LUB를 계산하고, 클로저 스코프 밖의 추론 타입으로 그 LUB를 사용해요. 예:
class Top {
void methodFromTop() {}
}
class Bottom extends Top {
void methodFromBottom() {}
}
def o = new Top() (1)
Thread.start {
o = new Bottom() (2)
}
o.methodFromTop() (3)
o.methodFromBottom() // compilation error (4)
(1) — 클로저 공유 변수에 먼저 Top이 할당돼요
(2) — 클로저 안에서 Bottom이 할당돼요
(3) — methodFromTop은 허용돼요
(4) — methodFromBottom은 컴파일 오류예요
여기서 methodFromBottom이 호출될 때 컴파일 시점이나 런타임에 o의 타입이 실제로 Bottom이 될 보장이 없다는 게 분명해요. 그럴 가능성은 있지만 비동기이므로 확실할 수 없어요. 그래서 타입 검사기는 최소 상한(여기서는 Top)에 대해서만 호출을 허용해요.
6.2.6. 클로저와 타입 추론
타입 검사기는 클로저에 대해 특별한 추론을 수행하며, 한편으로는 추가 검사를 하고 다른 한편으로는 유창함(fluency)을 향상시켜요.
반환 타입 추론
타입 검사기가 할 수 있는 첫 번째 일은 클로저의 반환 타입을 추론하는 것이에요. 다음 예시에서 간단히 확인할 수 있어요:
@groovy.transform.TypeChecked
int testClosureReturnTypeInference(String arg) {
def cl = { "Arg: $arg" } (1)
def val = cl() (2)
val.length() (3)
}
(1) — 클로저가 정의되고 문자열(더 정확히는 GString)을 반환해요
(2) — 클로저를 호출하고 결과를 변수에 할당해요
(3) — 타입 검사기가 클로저가 문자열을 반환할 것으로 추론했으므로 length() 호출이 허용돼요
보시다시피 반환 타입을 명시적으로 선언하는 메서드와 달리, 클로저의 반환 타입을 선언할 필요가 없어요: 그 타입은 클로저 본문에서 추론돼요.
클로저 vs 메서드
반환 타입 추론은 클로저에만 적용된다는 점에 주목할 가치가 있어요. 타입 검사기가 메서드에 대해서도 같은 일을 할 수 있지만 실질적으로 바람직하지 않아요: 일반적으로 메서드는 오버라이드될 수 있고, 호출되는 메서드가 오버라이드된 버전이 아니라는 것을 정적으로 확실히 할 수 없어요. 그래서 플로 타이핑은 메서드가 실제로는 다른 것을 반환할 수 있는데도 뭔가를 반환한다고 생각할 수 있어요. 다음 예시처럼요:
@TypeChecked
class A {
def compute() { 'some string' } (1)
def computeFully() {
compute().toUpperCase() (2)
}
}
@TypeChecked
class B extends A {
def compute() { 123 } (3)
}
(1) — 클래스 A는 실제로 String을 반환하는 메서드 compute를 정의해요
(2) — compute의 반환 타입이 def(일명 Object)이므로 컴파일이 실패해요
(3) — 클래스 B는 A를 확장하고 compute를 재정의하는데, 이번엔 int를 반환해요
보시다시피 타입 검사기가 메서드의 추론 반환 타입에 의존한다면, 플로 타이핑으로 toUpperCase를 호출해도 좋다고 판단할 수 있어요. 하지만 실제로는 오류인데, 서브클래스가 compute를 오버라이드해 다른 객체를 반환할 수 있기 때문이에요. 여기서 B#compute는 int를 반환하므로 B 인스턴스에서 computeFully를 호출하는 사람은 런타임 오류를 보게 돼요. 컴파일러는 추론 반환 타입 대신 메서드의 선언된 반환 타입을 사용해 이런 일을 방지해요.
일관성을 위해 이 동작은 static이나 final이든 모든 메서드에 동일해요.
파라미터 타입 추론
반환 타입 외에도 클로저는 컨텍스트에서 파라미터 타입을 추론할 수 있어요. 컴파일러가 파라미터 타입을 추론하는 방법은 두 가지가 있어요:
- 암시적 SAM 타입 강제 변환을 통해
- API 메타데이터를 통해
이를 설명하기 위해, 타입 검사기가 파라미터 타입을 추론하지 못해 컴파일이 실패하는 예시부터 시작할게요:
class Person {
String name
int age
}
void inviteIf(Person p, Closure<Boolean> predicate) { (1)
if (predicate.call(p)) {
// send invite
// ...
}
}
@groovy.transform.TypeChecked
void failCompilation() {
Person p = new Person(name: 'Gerard', age: 55)
inviteIf(p) { (2)
it.age >= 18 // No such property: age (3)
}
}
(1) — inviteIf 메서드는 Person과 Closure를 받아요
(2) — Person과 Closure로 호출해요
(3) — 하지만 it은 Person으로 정적으로 알려져 있지 않아 컴파일이 실패해요
이 예시에서 클로저 본문은 it.age를 포함해요. 동적이고 타입 검사되지 않는 코드에서는 it의 타입이 런타임에 Person이므로 동작해요. 안타깝게도 컴파일 시점에는 inviteIf의 시그니처만 읽어서는 it의 타입이 무엇인지 알 방법이 없어요.
명시적 클로저 파라미터
요약하면, 타입 검사기는 inviteIf 메서드에 대해 it의 타입을 정적으로 결정할 충분한 컨텍스트 정보가 없어요. 즉 메서드 호출을 이렇게 다시 써야 해요:
inviteIf(p) { Person it -> (1)
it.age >= 18
}
(1) — it의 타입을 명시적으로 선언해야 해요
it 변수의 타입을 명시적으로 선언하면 문제를 해결하고 이 코드를 정적으로 검사할 수 있어요.
단일 추상 메서드 타입에서 추론된 파라미터
API나 프레임워크 설계자라면 사용자가 클로저 파라미터에 명시적 타입을 선언하지 않아도 되도록 이 문제를 더 우아하게 만드는 두 가지 방법이 있어요. 첫 번째이자 가장 쉬운 방법은 클로저를 SAM 타입으로 바꾸는 것이에요:
interface Predicate<On> { boolean apply(On e) } (1)
void inviteIf(Person p, Predicate<Person> predicate) { (2)
if (predicate.apply(p)) {
// send invite
// ...
}
}
@groovy.transform.TypeChecked
void passesCompilation() {
Person p = new Person(name: 'Gerard', age: 55)
inviteIf(p) { (3)
it.age >= 18 (4)
}
}
(1) — apply 메서드가 있는 SAM 인터페이스를 선언해요
(2) — inviteIf는 이제 Closure<Boolean> 대신 Predicate<Person>을 사용해요
(3) — 더 이상 it 변수의 타입을 선언할 필요가 없어요
(4) — it.age는 제대로 컴파일되고, it의 타입은 Predicate#apply 메서드 시그니처에서 추론돼요
이 기법으로 우리는 클로저를 SAM 타입으로 자동 강제 변환하는 Groovy 기능을 활용합니다. SAM 타입과 Closure 중 무엇을 써야 하는지는 실제로 무엇을 해야 하는지에 달려 있어요. 많은 경우 SAM 인터페이스를 사용하는 것으로 충분하며, 특히 Java 8에서 볼 수 있는 함수형 인터페이스를 생각하면 그렇습니다. 하지만 클로저는 함수형 인터페이스에서 접근할 수 없는 기능들을 제공해요. 특히 클로저는 delegate와 owner를 가질 수 있고, 호출되기 전에 객체로 조작될 수 있어요(예: clone, serialize, curry 등). 또한 여러 시그니처(다형성)를 지원할 수 있어요. 그래서 그런 종류의 조작이 필요하다면 아래에 설명되는 더 고급 타입 추론 어노테이션으로 전환하는 것이 바람직합니다.
클로저 파라미터 타입 추론, 즉 파라미터를 명시적으로 선언하지 않고도 클로저 인자의 타입을 정적으로 결정하는 것과 관련해 해결해야 할 원래 문제는, Groovy 타입 시스템이 Java 타입 시스템을 물려받는데 그 시스템이 인자의 타입을 설명하기엔 불충분하다는 것이에요.
@ClosureParams 어노테이션
Groovy는 타입 정보를 완성하는 것을 목표로 하는 @ClosureParams 어노테이션을 제공해요. 이 어노테이션은 주로 타입 추론 메타데이터를 제공해 타입 검사기의 능력을 확장하고자 하는 프레임워크·API 개발자를 겨냥해요. 라이브러리가 클로저를 사용하고 최대 수준의 툴링 지원도 원한다면 이게 중요해요.
@ClosureParams 어노테이션을 도입해 원래 예시를 고쳐 설명할게요:
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.FirstParam
void inviteIf(Person p, @ClosureParams(FirstParam) Closure<Boolean> predicate) { (1)
if (predicate.call(p)) {
// send invite
// ...
}
}
inviteIf(p) { (2)
it.age >= 18
}
(1) — 클로저 파라미터가 @ClosureParams로 어노테이션되었어요
(2) — it에 명시적 타입을 사용할 필요가 없으며, 추론돼요
@ClosureParams 어노테이션은 최소한 하나의 인자를 받는데, 그것을 타입 힌트(type hint)라고 불러요. 타입 힌트는 컴파일 시점에 클로저의 타입 정보를 완성하는 책임이 있는 클래스예요. 이 예시에서 사용된 타입 힌트는 groovy.transform.stc.FirstParam으로, 타입 검사기에게 클로저가 메서드의 첫 번째 파라미터 타입과 같은 타입의 파라미터 하나를 받을 것임을 나타내요. 이 경우 메서드의 첫 번째 파라미터가 Person이므로, 클로저의 첫 번째 파라미터가 사실상 Person임을 타입 검사기에 알려줘요.
두 번째 선택적 인자는 options라고 불러요. 그 시맨틱은 타입 힌트 클래스에 따라 달라져요. Groovy에는 아래 표에 설명된 다양한 번들 타입 힌트가 있어요:
| 타입 힌트 | 다형성? | 설명과 예시 |
|---|---|---|
FirstParam SecondParam ThirdParam |
아니오 | 메서드의 첫 번째(각각 두 번째, 세 번째) 파라미터 타입. import groovy.transform.stc.FirstParam; void doSomething(String str, @ClosureParams(FirstParam) Closure c) { c(str) } doSomething('foo') { println it.toUpperCase() } import groovy.transform.stc.SecondParam; void withHash(String str, int seed, @ClosureParams(SecondParam) Closure c) { c(31*str.hashCode()+seed) } withHash('foo', (int)System.currentTimeMillis()) { int mod = it%2 } import groovy.transform.stc.ThirdParam; String format(String prefix, String postfix, String o, @ClosureParams(ThirdParam) Closure c) { "$prefix${c(o)}$postfix" } assert format('foo', 'bar', 'baz') { it.toUpperCase() } == 'fooBAZbar' |
FirstParam.FirstGenericType SecondParam.FirstGenericType ThirdParam.FirstGenericType |
아니오 | 메서드의 첫 번째(각각 두 번째, 세 번째) 파라미터의 첫 번째 제네릭 타입. import groovy.transform.stc.FirstParam; public <T> void doSomething(List<T> strings, @ClosureParams(FirstParam.FirstGenericType) Closure c) { strings.each { c(it) } } doSomething(['foo','bar']) { println it.toUpperCase() } doSomething([1,2,3]) { println(2*it) } 모든 FirstParam, SecondParam, ThirdParam 타입 힌트에 대해 SecondGenericType, ThirdGenericType 변형도 존재해요. |
SimpleType |
아니오 | 클로저 파라미터 타입이 options 문자열에서 오는 타입 힌트. import groovy.transform.stc.SimpleType; public void doSomething(@ClosureParams(value=SimpleType,options=['java.lang.String','int']) Closure c) { c('foo',3) } doSomething { str, len -> assert str.length() == len } 이 타입 힌트는 단일 시그니처를 지원하고 각 파라미터는 options 배열의 값으로 완전한 타입 이름 또는 기본 타입으로 지정됩니다. |
MapEntryOrKeyValue |
예 | 단일 Map.Entry 파라미터로 동작하거나 키·값 두 파라미터로 동작하는 클로저를 위한 전용 타입 힌트. import groovy.transform.stc.MapEntryOrKeyValue; public <K,V> void doSomething(Map<K,V> map, @ClosureParams(MapEntryOrKeyValue) Closure c) { // ... } doSomething([a: 'A']) { k,v -> assert k.toUpperCase() == v.toUpperCase() } doSomething([abc: 3]) { e -> assert e.key.length() == e.value } 이 타입 힌트는 첫 번째 인자가 Map 타입이어야 하고, 맵의 실제 키/값 타입에서 클로저 파라미터 타입을 추론합니다. |
FromAbstractTypeMethods |
예 | 어떤 타입의 추상 메서드에서 클로저 파라미터 타입을 추론해요. 각 추상 메서드에 대해 시그니처가 추론돼요. import groovy.transform.stc.FromAbstractTypeMethods; abstract class Foo { abstract void firstSignature(int x, int y); abstract void secondSignature(String str) } void doSomething(@ClosureParams(value=FromAbstractTypeMethods, options=["Foo"]) Closure cl) { // ... } doSomething { a, b -> a+b } doSomething { s -> s.toUpperCase() } 위 예시처럼 여러 시그니처가 있으면, 타입 검사기는 각 메서드의 인자 수(arity)가 다를 때만 인자 타입을 추론할 수 있어요. 위 예시에서 firstSignature는 2개 인자, secondSignature는 1개 인자를 받으므로 타입 검사기가 인자 수를 기반으로 인자 타입을 추론할 수 있어요. 다음에 논의될 선택적 resolver 클래스 속성도 참고하세요. |
FromString |
예 | options 인자에서 클로저 파라미터 타입을 추론해요. options 인자는 쉼표로 구분된 비-기본 타입들의 배열로 구성돼요. 배열의 각 요소는 단일 시그니처에 해당하고, 요소의 각 쉼표는 시그니처의 파라미터를 구분해요. 간단히 말해 이것이 가장 일반적인 타입 힌트이며, options 맵의 각 문자열은 시그니처 리터럴처럼 파싱됩니다. 매우 강력하지만, 타입 시그니처를 파싱해야 하므로 컴파일 시간이 늘어나기 때문에 가능하면 피해야 해요. String을 받는 단일 시그니처: import groovy.transform.stc.FromString; void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) { // ... } doSomething { s -> s.toUpperCase() } doSomething { s,i -> s.toUpperCase()*i } String 또는 String, Integer를 받는 다형성 클로저: import groovy.transform.stc.FromString; void doSomething(@ClosureParams(value=FromString, options=["String","String,Integer"]) Closure cl) { // ... } doSomething { s -> s.toUpperCase() } doSomething { s,i -> s.toUpperCase()*i } T 또는 쌍 T,T를 받는 다형성 클로저: import groovy.transform.stc.FromString; public <T> void doSomething(T e, @ClosureParams(value=FromString, options=["T","T,T"]) Closure cl) { // ... } doSomething('foo') { s -> s.toUpperCase() } doSomething('foo') { s1,s2 -> assert s1.toUpperCase() == s2.toUpperCase() } |
타입 힌트로
FirstParam,SecondParam,ThirdParam을 사용해도, 클로저에 전달될 인자가 반드시 메서드 호출의 첫 번째(각각 두 번째, 세 번째) 인자라는 것을 엄밀히 의미하지는 않아요. 단지 클로저 파라미터의 타입이 메서드 호출의 첫 번째(각각 두 번째, 세 번째) 인자의 타입과 같다는 것만 의미합니다.
요약하면, Closure를 받는 메서드에 @ClosureParams 어노테이션이 없다고 컴파일이 실패하지는 않아요. 어노테이션이 있으면(Groovy 소스뿐 아니라 Java 소스에도 있을 수 있음) 타입 검사기는 더 많은 정보를 갖고 추가 타입 추론을 수행할 수 있어요. 이것이 이 기능을 프레임워크 개발자에게 특히 흥미롭게 만들어요.
세 번째 선택적 인자는 conflictResolutionStrategy라고 불러요. 초기 추론 계산이 끝난 후 파라미터 타입이 둘 이상 발견되면 추가 해석을 수행할 수 있는 클래스(ClosureSignatureConflictResolver를 확장한)를 참조할 수 있어요. Groovy는 아무것도 하지 않는 기본 타입 리졸버와, 여러 개가 발견되면 첫 번째 시그니처를 선택하는 또 하나를 기본으로 제공해요. 리졸버는 시그니처가 둘 이상 발견될 때만 호출되며 설계상 후처리기(post processor)예요. 주입된 타이핑 정보가 필요한 어떤 문장이든 타입 힌트를 통해 결정된 파라미터 시그니처 중 하나를 통과해야 해요. 그러면 리졸버가 반환된 후보 시그니처 중에서 선택해요.
@DelegatesTo
@DelegatesTo 어노테이션은 타입 검사기가 delegate의 타입을 추론하는 데 사용해요. API 설계자가 컴파일러에게 delegate의 타입과 위임 전략(delegation strategy)을 지시할 수 있게 해줘요. @DelegatesTo 어노테이션은 특정 섹션에서 다룹니다.
6.3. 정적 컴파일(Static compilation)
6.3.1. 동적 vs 정적
타입 검사 섹션에서 @TypeChecked 어노테이션 덕분에 Groovy가 선택적 타입 검사를 제공하는 것을 보았어요. 타입 검사기는 컴파일 시점에 실행되고 동적 코드의 정적 분석을 수행해요. 프로그램은 타입 검사가 활성화되었든 아니든 정확히 똑같이 동작해요. 즉 @TypeChecked 어노테이션은 프로그램의 시맨틱에 대해 중립적이에요. 프로그램이 타입 안전하다고 간주되도록 소스에 타입 정보를 추가해야 할 수도 있지만, 결국 프로그램의 시맨틱은 동일해요.
괜찮아 보일지 몰라도 여기에는 실제로 한 가지 문제가 있어요: 컴파일 시점에 수행되는 동적 코드의 타입 검사는 정의상 런타임 특정 동작이 발생하지 않을 때만 올바르다는 것. 예를 들어 다음 프로그램은 타입 검사를 통과해요:
class Computer {
int compute(String str) {
str.length()
}
String compute(int x) {
String.valueOf(x)
}
}
@groovy.transform.TypeChecked
void test() {
def computer = new Computer()
computer.with {
assert compute(compute('foobar')) =='6'
}
}
두 개의 compute 메서드가 있어요. 하나는 String을 받고 int를 반환하고, 다른 하나는 int를 받고 String을 반환해요. 이것을 컴파일하면 타입 안전하다고 간주돼요: 내부의 compute('foobar') 호출은 int를 반환하고, 이 int에 compute를 호출하면 String을 반환해요.
이제 test()를 호출하기 전에 다음 줄을 추가하는 것을 고려해 볼게요:
Computer.metaClass.compute = { String str -> new Date() }
런타임 메타프로그래밍을 사용해 compute(String) 메서드의 동작을 실제로 수정해서, 제공된 인자의 길이를 반환하는 대신 Date를 반환하게 해요. 프로그램을 실행하면 런타임에 실패해요. 이 줄은 어디서든, 어떤 스레드에서든 추가될 수 있으므로, 타입 검사기가 그런 일이 일어나지 않는다고 정적으로 확신할 방법이 전혀 없어요. 요약하면 타입 검사기는 몽키 패칭(monkey patching)에 취약해요. 이것은 한 가지 예시일 뿐이지만, 동적 프로그램의 정적 분석이 본질적으로 틀릴 수 있다는 개념을 보여줘요.
Groovy 언어는 타입 검사기가 호출될 것으로 추론한 메서드가 런타임에 실제로 호출되도록 보장하는 @TypeChecked의 대안 어노테이션을 제공해요. 이 어노테이션은 Groovy 컴파일러를 정적 컴파일러로 바꾸는데, 모든 메서드 호출이 컴파일 시점에 해석되고 생성된 바이트코드가 이것이 일어나도록 보장해요: 그 어노테이션은 @groovy.transform.CompileStatic이에요.
6.3.2. @CompileStatic 어노테이션
@CompileStatic 어노테이션은 @TypeChecked를 사용할 수 있는 어디에나, 즉 클래스나 메서드에 추가할 수 있어요. @TypeChecked와 @CompileStatic을 둘 다 추가할 필요는 없는데, @CompileStatic이 @TypeChecked가 하는 모든 것을 수행하지만 추가로 정적 컴파일을 촉발하기 때문이에요.
실패했던 예시를 가져와 이번에는 @TypeChecked 어노테이션을 @CompileStatic으로 바꿔 볼게요:
class Computer {
int compute(String str) {
str.length()
}
String compute(int x) {
String.valueOf(x)
}
}
@groovy.transform.CompileStatic
void test() {
def computer = new Computer()
computer.with {
assert compute(compute('foobar')) =='6'
}
}
Computer.metaClass.compute = { String str -> new Date() }
test()
이것이 유일한 차이예요. 프로그램을 실행하면 이번에는 런타임 오류가 없어요. test 메서드는 몽키 패칭에 면역이 돼요, 왜냐하면 그 본문에서 호출되는 compute 메서드가 컴파일 시점에 연결되기 때문이에요. 그래서 Computer의 metaclass가 바뀌어도 프로그램은 여전히 타입 검사기가 기대한 대로 동작해요.
6.3.3. 핵심 이점
코드에 @CompileStatic을 사용하면 여러 이점이 있어요:
- 타입 안전성
- 몽키 패칭에 대한 면역성
- 성능 개선
성능 개선은 실행 중인 프로그램의 종류에 따라 달라져요. I/O 중심이면 정적으로 컴파일된 코드와 동적 코드의 차이는 거의 눈에 띄지 않아요. CPU 집약적인 코드에서는, 생성된 바이트코드가 동등한 프로그램에 대해 Java가 생성하는 것과 매우 가깝거나 같기 때문에 성능이 크게 향상돼요.
JDK 7 이상을 사용하는 사람들이 접근할 수 있는 invokedynamic 버전의 Groovy를 사용하면, 동적 코드의 성능은 정적으로 컴파일된 코드의 성능에 매우 가까워야 해요. 때로는 더 빠를 수도 있어요! 어느 버전을 골라야 하는지 결정하는 방법은 오직 하나뿐입니다: 측정. 프로그램과 사용 중인 JVM에 따라 성능이 크게 달라질 수 있기 때문이에요. 특히 invokedynamic 버전의 Groovy는 사용 중인 JVM 버전에 매우 민감합니다.
7. 타입 검사 확장(Type checking extensions)
7.1. 타입 검사 확장 작성하기
7.1.1. 더 똑똑한 타입 검사기를 향해
동적 언어임에도 Groovy는 컴파일 시점에 정적 타입 검사기와 함께 사용할 수 있으며, @TypeChecked 어노테이션으로 활성화돼요. 이 모드에서 컴파일러는 더 장황해지고, 예를 들어 오타, 존재하지 않는 메서드 등에 대해 오류를 던져요. 다만 몇 가지 제한이 있는데, 대부분 Groovy가 본질적으로 동적 언어라는 사실에서 비롯돼요. 예를 들어 마크업 빌더를 사용하는 코드에는 타입 검사를 사용할 수 없어요:
def builder = new MarkupBuilder(out)
builder.html {
head {
// ...
}
body {
p 'Hello, world!'
}
}
이전 예시에서 html, head, body, p 메서드 중 어느 것도 존재하지 않아요. 하지만 코드를 실행하면 동작해요, 왜냐하면 Groovy가 동적 디스패치를 사용해 런타임에 그 메서드 호출을 변환하기 때문이에요. 이 빌더에서는 사용할 수 있는 태그 수에 제한이 없고 속성에도 제한이 없으므로, HTML 전용 빌더를 만들지 않는 한 타입 검사기가 컴파일 시점에 가능한 모든 메서드(태그)를 알 기회가 없어요.
Groovy는 내부 DSL을 구현하는 데 선택되는 플랫폼이에요. 유연한 문법과 런타임·컴파일 시점 메타프로그래밍 능력이 결합되어 Groovy를 흥미로운 선택지로 만들어요, 왜냐하면 프로그래머가 툴링이나 구현보다 DSL에 집중할 수 있게 해주기 때문이에요. Groovy DSL은 Groovy 코드이므로 전용 플러그인을 작성하지 않고도 IDE 지원을 쉽게 얻을 수 있어요.
많은 경우 DSL 엔진이 Groovy(또는 Java)로 작성된 다음 사용자 코드가 스크립트로 실행되며, 사용자 로직 위에 일종의 래퍼가 있는 셈이에요. 래퍼는 예를 들어 스크립트를 실행하기 전에 투명하게 일부 작업을 수행하는(import 추가, AST 변환 적용, 기본 스크립트 확장 등) GroovyShell이나 GroovyScriptEngine으로 구성될 수 있어요. 종종 사용자가 작성한 스크립트는 DSL 로직이 어떤 사용자도 DSL 문법으로 코드를 작성할 수 있는 지점에 이르기 때문에 테스트 없이 프로덕션에 들어가요. 결국 사용자는 자신이 쓰는 것이 실제로 코드라는 것을 무시할 수도 있어요. 이것은 사용자 코드 실행 보안이나, 여기서의 경우 오류의 조기 보고 같은 DSL 구현자에게 몇 가지 도전 과제를 추가해요.
예를 들어, 화성에서 로버를 원격으로 운전하는 것을 목표로 하는 DSL을 상상해 봐요. 로버에 메시지를 보내는 데 약 15분이 걸려요. 로버가 스크립트를 실행하고 오류(예: 오타)로 실패하면 두 가지 문제가 있어요:
- 첫째, 피드백이 30분 후에만 옵니다(로버가 스크립트를 받는 데 걸리는 시간과 오류를 받는 데 걸리는 시간).
- 둘째, 스크립트의 일부가 실행되었고 고정된 스크립트를 크게 변경해야 할 수도 있어요(로버의 현재 상태를 알아야 한다는 뜻).
타입 검사 확장은 DSL 엔진 개발자가 일반 Groovy 클래스에서 정적 타입 검사가 허용하는 것과 같은 종류의 검사를 적용해 이런 스크립트를 더 안전하게 만들 수 있게 해주는 메커니즘이에요.
여기의 원칙은 일찍 실패하는 것, 즉 가능한 한 빨리 스크립트의 컴파일을 실패시키고 가능하면 사용자에게 피드백(멋진 오류 메시지 포함)을 제공하는 것이에요.
요약하면 타입 검사 확장 뒤의 아이디어는 DSL이 사용하는 모든 런타임 메타프로그래밍 트릭을 컴파일러가 인지하도록 만들어, 스크립트가 장황한 정적으로 컴파일된 코드가 가지는 것과 같은 수준의 컴파일 시점 검사를 누릴 수 있게 하는 것이에요. 일반 타입 검사기가 하지 않을 검사를 수행해 사용자에게 강력한 컴파일 시점 검사를 제공하는 것까지 더 나아갈 수 있다는 것도 볼 수 있을 거예요.
7.1.2. extensions 속성
@TypeChecked 어노테이션은 extensions라는 속성을 지원해요. 이 파라미터는 타입 검사 확장 스크립트 목록에 해당하는 문자열 배열을 받아요. 그 스크립트들은 컴파일 시점에 classpath에서 발견돼요. 예를 들어 다음과 같이 작성해요:
@TypeChecked(extensions='/path/to/myextension.groovy')
void foo() { ...}
이 경우 foo 메서드는 일반 타입 검사기의 규칙에 myextension.groovy 스크립트에서 발견된 규칙이 완성된 상태로 타입 검사돼요. 내부적으로 타입 검사기는 타입 검사 확장을 구현하는 여러 메커니즘(평범한 Java 코드 포함)을 지원하지만, 권장되는 방법은 이런 타입 검사 확장 스크립트를 사용하는 것이라는 점에 주목하세요.
7.1.3. 타입 검사를 위한 DSL
타입 검사 확장 뒤의 아이디어는 DSL을 사용해 타입 검사기 능력을 확장하는 것이에요. 이 DSL은 "이벤트 중심" API를 사용해 컴파일 프로세스, 더 구체적으로 타입 검사 단계에 훅을 걸게 해줘요. 예를 들어 타입 검사기가 메서드 본문에 들어가면 확장이 반응할 수 있는 beforeVisitMethod 이벤트를 던져요:
beforeVisitMethod { methodNode ->
println "Entering ${methodNode.name}"
}
이 로버 DSL이 있다고 상상해 볼게요. 사용자는 다음과 같이 작성해요:
robot.move 100
다음과 같은 클래스가 정의되어 있다면:
class Robot {
Robot move(int qt) { this }
}
다음 스크립트를 사용해 실행 전에 타입 검사할 수 있어요:
def config = new CompilerConfiguration()
config.addCompilationCustomizers(
new ASTTransformationCustomizer(TypeChecked) (1)
)
def shell = new GroovyShell(config) (2)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script) (3)
(1) — 컴파일러 설정이 모든 클래스에 @TypeChecked 어노테이션을 추가해요
(2) — 설정을 GroovyShell에서 사용해요
(3) — 그래서 shell을 사용해 컴파일된 스크립트는 사용자가 명시적으로 추가하지 않아도 @TypeChecked로 컴파일돼요
위 컴파일러 설정을 사용하면 스크립트에 @TypeChecked를 투명하게 적용할 수 있어요. 그 경우 컴파일 시점에 실패해요:
[Static type checking] - The variable [robot] is undeclared.
이제 설정을 살짝 업데이트해 ``extensions'' 파라미터를 포함시킬게요:
config.addCompilationCustomizers(
new ASTTransformationCustomizer(
TypeChecked,
extensions:['robotextension.groovy'])
)
그런 다음 classpath에 다음을 추가해요:
unresolvedVariable { var ->
if ('robot'==var.name) {
storeType(var, classNodeFor(Robot))
handled = true
}
}
여기서 컴파일러에게, unresolved variable(해석되지 않은 변수)이 발견되었고 그 변수의 이름이 robot이면, 이 변수의 타입이 Robot임을 확신할 수 있다고 알려주고 있어요.
7.1.4. 타입 검사 확장 API
AST
타입 검사 API는 추상 구문 트리(AST)를 다루는 저수준 API예요. 확장을 개발하려면 AST를 잘 알아야 해요, 비록 DSL이 평범한 Java나 Groovy에서 AST 코드를 다루는 것보다 훨씬 쉽게 만들어 주지만요.
이벤트
타입 검사기는 다음 이벤트를 보내며, 확장 스크립트가 그것에 반응할 수 있어요:
setup
- 호출 시점: 타입 검사기가 초기화를 완료한 후 호출돼요.
- 인자: 없음
- 사용법:
setup { // this is called before anything else }— 확장의 설정을 수행하는 데 사용할 수 있어요.
finish
- 호출 시점: 타입 검사기가 타입 검사를 완료한 후 호출돼요.
- 인자: 없음
- 사용법:
finish { // this is after completion // of all type checking }— 타입 검사기가 작업을 마친 후 추가 검사를 수행하는 데 사용할 수 있어요.
unresolvedVariable
- 호출 시점: 타입 검사기가 해석되지 않은 변수를 찾을 때 호출돼요.
- 인자:
VariableExpression vexp - 사용법:
unresolvedVariable { VariableExpression vexp -> if (vexp.name == 'people') { storeType(vexp, LIST_TYPE) handled = true } }— 개발자가 사용자 주입 변수로 타입 검사기를 돕게 해줘요.
unresolvedProperty
- 호출 시점: 타입 검사기가 수신자(receiver)에서 프로퍼티를 찾을 수 없을 때 호출돼요.
- 인자:
PropertyExpression pexp - 사용법:
unresolvedProperty { PropertyExpression pexp -> if (pexp.propertyAsString == 'longueur' && getType(pexp.objectExpression) == STRING_TYPE) { storeType(pexp, int_TYPE) handled = true } }— "동적" 프로퍼티를 처리하게 해줘요.
unresolvedAttribute
- 호출 시점: 타입 검사기가 수신자에서 속성(attribute)을 찾을 수 없을 때 호출돼요.
- 인자:
AttributeExpression aexp - 사용법:
unresolvedAttribute { AttributeExpression aexp -> if (getType(aexp.objectExpression) == STRING_TYPE) { storeType(aexp, STRING_TYPE) handled = true } }— 누락된 속성을 처리하게 해줘요.
beforeMethodCall
- 호출 시점: 타입 검사기가 메서드 호출 타입 검사를 시작하기 전에 호출돼요.
- 인자:
MethodCall call - 사용법:
beforeMethodCall { call -> if (isMethodCallExpression(call) && call.methodAsString=='toUpperCase') { addStaticTypeError('Not allowed',call) handled = true } }— 타입 검사기가 자신의 검사를 수행하기 전에 메서드 호출을 가로채게 해줘요. 제한된 범위에 대해 기본 타입 검사를 사용자 정의 검사로 바꾸고 싶을 때 유용해요. 그 경우 handled 플래그를 true로 설정해서 타입 검사기가 자신의 검사를 건너뛰게 해야 해요.
afterMethodCall
- 호출 시점: 타입 검사기가 메서드 호출 타입 검사를 마친 후 호출돼요.
- 인자:
MethodCall call - 사용법:
afterMethodCall { call -> if (getTargetMethod(call).name=='toUpperCase') { addStaticTypeError('Not allowed',call) handled = true } }— 타입 검사기가 자신의 검사를 수행한 후 추가 검사를 수행하게 해줘요. 표준 타입 검사 테스트를 수행하면서도 추가 타입 안전성을 보장하고 싶을 때 특히 유용해요, 예를 들어 인자들을 서로 검사하는 것처럼요. afterMethodCall은 beforeMethodCall을 하고 handled 플래그를 true로 설정했어도 호출된다는 것에 주목하세요.
onMethodSelection
- 호출 시점: 타입 검사기가 메서드 호출에 적절한 메서드를 찾을 때 호출돼요.
- 인자:
Expression expr, MethodNode node - 사용법:
onMethodSelection { expr, node -> if (node.declaringClass.name == 'java.lang.String') { // calling a method on 'String' // let's perform additional checks! if (++count>2) { addStaticTypeError("You can use only 2 calls on String in your source code",expr) } } }— 타입 검사기는 메서드 호출의 인자 타입을 추론한 다음 대상 메서드를 선택해요. 대응하는 것을 찾으면 이 이벤트를 촉발해요. 특정 메서드 호출에 반응하고 싶을 때(빌더처럼 클로저를 인자로 받는 메서드의 스코프에 들어갈 때) 흥미로워요. 이 이벤트는 메서드 호출뿐 아니라 다양한 종류의 표현식(예: 이항 표현식)에 대해서도 던져질 수 있다는 점에 주목하세요.
methodNotFound
- 호출 시점: 타입 검사기가 메서드 호출에 적절한 메서드를 찾지 못할 때 호출돼요.
- 인자:
ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes, MethodCall call - 사용법:
methodNotFound { receiver, name, argList, argTypes, call -> // receiver is the inferred type of the receiver // name is the name of the called method // argList is the list of arguments the method was called with // argTypes is the array of inferred types for each argument // call is the method call for which we couldn't find a target method if (receiver==classNodeFor(String) && name=='longueur' && argList.size()==0) { handled = true return newMethod('longueur', classNodeFor(String)) } }— onMethodSelection과 달리 이 이벤트는 타입 검사기가 메서드 호출(인스턴스든 정적이든)에 대한 대상 메서드를 찾을 수 없을 때 보내져요. 오류가 사용자에게 보내지기 전에 가로채고 대상 메서드를 설정할 기회를 줘요. 이를 위해MethodNode리스트를 반환해야 해요. 대부분의 상황에서: 대응 메서드를 찾지 못했다는 의미의 빈 리스트, 또는 대상 메서드에 의심의 여지가 없다는 뜻의 정확히 한 요소를 가진 리스트를 반환하면 돼요. 둘 이상의MethodNode를 반환하면 컴파일러는 가능한 메서드를 나열하면서 메서드 호출이 모호하다는 오류를 사용자에게 던져요. 편의를 위해 한 메서드만 반환하려면 리스트로 감싸지 않고 직접 반환하는 것이 허용돼요.
beforeVisitMethod
- 호출 시점: 타입 검사기가 메서드 본문을 타입 검사하기 전에 호출돼요.
- 인자:
MethodNode node - 사용법:
beforeVisitMethod { methodNode -> // tell the type checker we will handle the body by ourselves handled = methodNode.name.startsWith('skip') }— 타입 검사기는 메서드 본문 타입 검사를 시작하기 전에 이 메서드를 호출해요. 예를 들어 타입 검사기가 하는 대신 스스로 타입 검사를 수행하고 싶다면 handled 플래그를 true로 설정해야 해요. 이 이벤트는 확장의 스코프를 정의하는 데도 사용할 수 있어요(예: method foo 안에 있을 때만 적용).
afterVisitMethod
- 호출 시점: 타입 검사기가 메서드 본문을 타입 검사한 후 호출돼요.
- 인자:
MethodNode node - 사용법:
afterVisitMethod { methodNode -> scopeExit { if (methods>2) { addStaticTypeError("Method ${methodNode.name} contains more than 2 method calls", methodNode) } } }— 타입 검사기가 메서드 본문을 방문한 후 추가 검사를 수행할 기회를 줘요. 정보를 수집하고 모든 것이 수집된 후 추가 검사를 수행하고 싶을 때 유용해요.
beforeVisitClass
- 호출 시점: 타입 검사기가 클래스 타입 검사 전에 호출돼요.
- 인자:
ClassNode node - 사용법:
beforeVisitClass { ClassNode classNode -> def name = classNode.nameWithoutPackage if (!(name[0] in 'A'..'Z')) { addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode) } }— 클래스가 타입 검사되면, 클래스를 방문하기 전에 이 이벤트가 보내져요. @TypeChecked로 어노테이션된 클래스 안에 정의된 내부 클래스의 경우도 마찬가지예요. 확장의 스코프를 정의하는 데 도움이 되거나, 타입 검사기의 방문을 사용자 정의 타입 검사 구현으로 완전히 대체할 수도 있어요. 그러려면 handled 플래그를 true로 설정해야 해요.
afterVisitClass
- 호출 시점: 타입 검사기가 타입 검사된 클래스의 방문을 마친 후 호출돼요.
- 인자:
ClassNode node - 사용법:
afterVisitClass { ClassNode classNode -> def name = classNode.nameWithoutPackage if (!(name[0] in 'A'..'Z')) { addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode) } }— 타입 검사기가 작업을 마친 후 타입 검사되는 모든 클래스에 대해 호출돼요. 여기에는 @TypeChecked로 어노테이션된 클래스와, 건너뛰어지지 않은 같은 클래스에 정의된 내부/익명 클래스가 포함돼요.
incompatibleAssignment
- 호출 시점: 타입 검사기가 할당이 잘못되었다고 생각할 때, 즉 할당의 우변이 좌변과 비호환이라고 생각할 때 호출돼요.
- 인자:
ClassNode lhsType, ClassNode rhsType, Expression assignment - 사용법:
incompatibleAssignment { lhsType, rhsType, expr -> if (isBinaryExpression(expr) && isAssignment(expr.operation.type)) { if (lhsType==classNodeFor(int) && rhsType==classNodeFor(Closure)) { handled = true } } }— 개발자에게 잘못된 할당을 처리할 수 있는 능력을 줘요. 예를 들어 클래스가 setProperty를 오버라이드하면 런타임 메커니즘으로 한 타입의 변수를 다른 타입의 프로퍼티에 할당하는 것이 처리될 수 있으므로 유용해요. 그 경우 handled를 true로 설정해 할당이 유효하다고 타입 검사기에 알려주면 돼요.
incompatibleReturnType
- 호출 시점: 타입 검사기가 반환 값이 둘러싼 클로저나 메서드의 반환 타입과 비호환이라고 생각할 때 호출돼요.
- 인자:
ReturnStatement statement, ClassNode valueType - 사용법:
incompatibleReturnType { stmt, type -> if (type == STRING_TYPE) { handled = true } }— 개발자에게 잘못된 반환 값을 처리할 수 있는 능력을 줘요. 반환 값이 암시적 변환을 거치거나 둘러싼 클로저의 대상 타입을 제대로 추론하기 어려울 때 유용해요. 그 경우 handled 프로퍼티를 설정해 할당이 유효하다고 타입 검사기에 알려주면 돼요.
ambiguousMethods
- 호출 시점: 타입 검사기가 여러 후보 메서드 사이에서 선택할 수 없을 때 호출돼요.
- 인자:
List<MethodNode> methods, Expression origin - 사용법:
ambiguousMethods { methods, origin -> // choose the method which has an Integer as parameter type methods.find { it.parameters.any { it.type == classNodeFor(Integer) } } }— 개발자에게 잘못된 할당을 처리할 수 있는 능력을 줘요. 클래스가 setProperty를 오버라이드하면 런타임 메커니즘으로 처리될 수 있으므로 유용해요. handled를 true로 설정해 할당이 유효하다고 알려주면 돼요.
물론 확장 스크립트는 여러 블록으로 구성될 수 있고, 같은 이벤트에 반응하는 여러 블록을 가질 수 있어요. 이는 DSL을 더 보기 좋고 쓰기 쉽게 만들어요. 하지만 이벤트에 반응하는 것은 충분하지 않아요. 이벤트에 반응할 수 있다고 해도 오류를 처리해야 하며, 이는 일을 쉽게 해주는 몇 가지 헬퍼 메서드를 수반해요.
7.1.5. 확장과 함께 작업하기
지원 클래스
DSL은 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport라는 지원 클래스에 의존해요. 이 클래스 자체는 org.codehaus.groovy.transform.stc.TypeCheckingExtension을 확장해요. 이 두 클래스는 AST, 특히 타입 검사와 관련해 작업을 더 쉽게 만들어주는 많은 헬퍼 메서드를 정의해요. 흥미로운 사실 하나는 타입 검사기에 접근할 수 있다는 것이에요. 즉 타입 검사기의 메서드를 프로그래밍 방식으로 호출할 수 있으며, 컴파일 오류를 던지게 하는 메서드도 포함해요.
확장 스크립트는 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport 클래스에 위임하므로 다음 변수에 직접 접근할 수 있어요:
- context: 타입 검사기 컨텍스트,
org.codehaus.groovy.transform.stc.TypeCheckingContext타입 - typeCheckingVisitor: 타입 검사기 자체,
org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor인스턴스 - generatedMethods: "생성된 메서드" 리스트, 실제로는 newMethod 호출로 타입 검사 확장 안에서 만들 수 있는 "더미" 메서드들의 리스트
타입 검사 컨텍스트는 타입 검사기에 유용한 많은 정보를 포함해요. 예를 들어 둘러싼 메서드 호출, 이항 표현식, 클로저의 현재 스택 등. 이 정보는 오류가 발생할 때 어디에 있는지 알아야 하고 그것을 처리하고 싶을 때 특히 중요해요.
GroovyTypeCheckingExtensionSupport와 StaticTypeCheckingVisitor가 제공하는 기능 외에도, 타입 검사 DSL 스크립트는 org.codehaus.groovy.ast.ClassHelper와 org.codehaus.groovy.transform.stc.StaticTypeCheckingSupport에서 정적 멤버를 import하며, OBJECT_TYPE, STRING_TYPE, THROWABLE_TYPE 등을 통해 공통 타입에 접근하고 missesGenericsTypes(ClassNode), isClassClassNodeWrappingConcreteType(ClassNode) 같은 검사에 접근하게 해줘요.
클래스 노드
타입 검사 확장으로 작업할 때 클래스 노드 처리에는 특별한 주의가 필요해요. 컴파일은 추상 구문 트리(AST)로 동작하며 클래스 타입 검사 시점에 트리가 완전하지 않을 수 있어요. 이것은 타입을 참조할 때 String이나 HashSet 같은 클래스 리터럴을 사용하면 안 되고, 그 타입을 나타내는 클래스 노드를 사용해야 한다는 것을 의미해요. 이는 일정 수준의 추상화와 Groovy가 클래스 노드를 다루는 방식에 대한 이해를 요구해요. 쉽게 하기 위해 Groovy는 클래스 노드를 다루는 여러 헬퍼 메서드를 제공해요. 예를 들어 "String의 타입"을 말하고 싶다면 다음과 같이 쓸 수 있어요:
assert classNodeFor(String) instanceof ClassNode
classNodeFor에는 Class 대신 String을 인자로 받는 변형도 있다는 점을 주목할 거예요. 일반적으로 그 버전을 사용하지 않아야 해요, 왜냐하면 이름이 String이지만 메서드·프로퍼티 등이 정의되지 않은 클래스 노드를 만들기 때문이에요. 첫 번째 버전은 해석된(resolved) 클래스 노드를 반환하지만 두 번째는 해석되지 않은 것을 반환해요. 그래서 후자는 아주 특별한 경우에만 예약되어야 해요.
만날 수 있는 두 번째 문제는 아직 컴파일되지 않은 타입을 참조하는 것이에요. 이는 생각보다 자주 발생할 수 있어요. 예를 들어 파일 집합을 함께 컴파일할 때요. 그 경우 "그 변수는 Foo 타입이다"라고 말하고 싶지만 Foo가 아직 컴파일되지 않았다면, lookupClassNodeFor를 사용해 여전히 Foo 클래스 노드를 참조할 수 있어요:
assert lookupClassNodeFor('Foo') instanceof ClassNode
타입 검사기 돕기
변수 foo가 Foo 타입이라는 것을 알고 있고 타입 검사기에 그것을 알려주고 싶다고 해 봐요. 그러면 storeType 메서드를 사용할 수 있어요. 이 메서드는 두 인자를 받아요: 첫 번째는 타입을 저장할 노드, 두 번째는 노드의 타입이에요. storeType의 구현을 보면 타입 검사기의 동등한 메서드에 위임하는데, 그 메서드 자체가 노드 메타데이터를 저장하는 많은 작업을 해요. 타입 저장이 변수에만 국한되지 않는 것도 볼 거예요: 어떤 표현식의 타입이든 설정할 수 있어요.
마찬가지로 AST 노드의 타입을 얻는 것은 노드에 getType을 호출하는 문제일 뿐이에요. 일반적으로 원하는 것이 이것이지만 이해해야 할 것이 있어요:
getType은 표현식의 추론 타입을 반환해요. 즉Object타입으로 선언된 변수에 대해 Object의 클래스 노드를 반환하지 않고, 코드의 이 시점에서 그 변수의 추론 타입을 반환해요(플로 타이핑).- 변수(또는 필드/파라미터)의 원래 타입에 접근하려면 AST 노드에서 적절한 메서드를 호출해야 해요.
오류 던지기
타입 검사 오류를 던지려면 두 인자를 받는 addStaticTypeError 메서드를 호출하기만 하면 돼요:
- 최종 사용자에게 표시될 문자열 메시지
- 오류 책임이 있는 AST 노드. 줄과 열 번호를 검색하는 데 사용되므로 가장 잘 맞는 AST 노드를 제공하는 것이 좋아요.
isXXXExpression
AST 노드의 타입을 알아야 하는 경우가 자주 있어요. 가독성을 위해 DSL은 x instanceof XXXExpression에 위임하는 특별한 isXXXExpression 메서드를 제공해요. 예를 들어 다음과 같이 쓰는 대신:
if (node instanceof BinaryExpression) {
...
}
그냥 이렇게 쓸 수 있어요:
if (isBinaryExpression(node)) {
...
}
가상 메서드(Virtual methods)
동적 코드의 타입 검사를 수행할 때 메서드 호출이 유효하다는 것을 알지만 그 뒤에 "실제" 메서드가 없는 경우를 자주 만날 수 있어요. 예로 Grails의 동적 finder를 들어 볼게요. findByName(…) 이라는 이름의 메서드 호출을 가질 수 있어요. 빈(bean)에 findByName 메서드가 정의되어 있지 않으므로 타입 검사기가 불평해요. 하지만 그 메서드가 런타임에 실패하지 않을 것임을 알 수 있고, 이 메서드의 반환 타입이 무엇인지도 말할 수 있어요. 이 경우 DSL은 팬텀 메서드(phantom method)로 구성된 두 가지 특별한 구조를 지원해요. 즉 실제로는 존재하지 않지만 타입 검사 맥락에서 정의된 메서드 노드를 반환하는 것이에요. 세 가지 메서드가 존재해요:
newMethod(String name, Class returnType)newMethod(String name, ClassNode returnType)newMethod(String name, Callable<ClassNode> return Type)
세 변형 모두 같은 일을 해요: 이름이 제공된 이름인 새 메서드 노드를 만들고 이 메서드의 반환 타입을 정의해요. 게다가 타입 검사기는 그 메서드들을 generatedMethods 리스트에 추가해요(아래 isGenerated 참조). 이름과 반환 타입만 설정하는 이유는 90%의 경우에 그것만 필요하기 때문이에요. 예를 들어 위의 findByName 예시에서 필요한 유일한 것은 findByName이 런타임에 실패하지 않는다는 것과 도메인 클래스를 반환한다는 것이에요. Callable 버전의 반환 타입은, 타입 검사기가 실제로 필요할 때 반환 타입 계산을 지연하기 때문에 흥미로워요. 어떤 상황에서는 타입 검사기가 요구할 때 실제 반환 타입을 모를 수 있으므로, 타입 검사기가 이 메서드 노드에 getReturnType을 호출할 때마다 호출될 클로저를 사용할 수 있기 때문이에요. 이것을 지연 검사(deferred checks)와 결합하면 정방향 참조(forward references) 처리까지 포함한 꽤 복잡한 타입 검사를 이룰 수 있어요.
newMethod(name) {
// each time getReturnType on this method node will be called, this closure will be called!
println 'Type checker called me!'
lookupClassNodeFor(Foo) // return type
}
이름과 반환 타입보다 더 많은 것이 필요하다면 언제든 직접 새 MethodNode를 만들 수 있어요.
스코프(Scoping)
스코프는 DSL 타입 검사에서 매우 중요하며, DSL 타입 검사에 포인트컷(pointcut) 기반 접근을 사용할 수 없었던 이유 중 하나예요. 기본적으로 확장이 적용되는 때와 적용되지 않는 때를 매우 정확하게 정의할 수 있어야 해요. 게다가 일반 타입 검사기가 처리하지 못하는 상황, 예를 들어 정방향 참조를 처리할 수 있어야 해요:
point a(1,1)
line a,b // b is referenced afterwards!
point b(5,2)
예를 들어 빌더를 처리하고 싶다고 해 봐요:
builder.foo {
bar
baz(bar)
}
그러면 확장은 foo 메서드에 들어간 후에만 활성화되고, 이 스코프 밖에서는 비활성화되어야 해요. 하지만 같은 파일에 여러 빌더가 있거나 내장 빌더(빌더 안의 빌더)가 있는 복잡한 상황이 있을 수 있어요. 처음부터 이 모든 것을 고치려고 해서는 안 되지만(타입 검사의 한계를 받아들여야 함), 타입 검사기는 이 문제를 처리하는 멋진 메커니즘을 제공해요: newScope와 scopeExit 메서드를 사용하는 스코프 스택이에요.
newScope는 새 스코프를 만들고 스택 맨 위에 놓아요.scopeExit는 스택에서 스코프를 꺼내요.
스코프는 다음으로 구성돼요:
- 부모 스코프
- 사용자 정의 데이터 맵
구현을 보고 싶다면 간단히 LinkedHashMap(org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport.TypeCheckingScope)이지만 꽤 강력해요. 예를 들어 그런 스코프를 사용해 스코프를 떠날 때 실행할 클로저 목록을 저장할 수 있어요. 이것이 정방향 참조를 처리하는 방법이에요:
def scope = newScope()
scope.secondPassChecks = []
//...
scope.secondPassChecks << { println 'executed later' }
// ...
scopeExit {
secondPassChecks*.run() // execute deferred checks
}
즉, 어떤 시점에서 표현식의 타입을 결정할 수 없거나 이 시점에 할당이 유효한지 확인할 수 없다면, 나중에 검사를 할 수 있다는 거예요… 매우 강력한 기능이에요. 이제 newScope와 scopeExit는 흥미로운 문법 설탕을 제공해요:
newScope {
secondPassChecks = []
}
DSL 어디에서든 getCurrentScope() 또는 더 간단히 currentScope로 현재 스코프에 접근할 수 있어요:
//...
currentScope.secondPassChecks << { println 'executed later' }
// ...
일반적인 구조는 다음과 같아요:
- 스코프를 스택에 푸시하고 이 스코프 안의 사용자 정의 변수를 초기화하는 포인트컷을 결정해요.
- 다양한 이벤트를 사용해 사용자 정의 스코프에 저장된 정보를 사용해 검사를 수행하고, 검사를 지연하는 등의 일을 해요.
- 스코프를 벗어나고 scopeExit를 호출하며 결국 추가 검사를 수행하는 포인트컷을 결정해요.
기타 유용한 메서드
헬퍼 메서드의 전체 목록은 org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport와 org.codehaus.groovy.transform.stc.TypeCheckingExtension 클래스를 참조하세요. 하지만 다음 메서드들에 특별한 주의를 기울이세요:
- isDynamic:
VariableExpression을 인자로 받고, 변수가 DynamicExpression이면(즉 스크립트에서 타입이나 def로 정의되지 않았으면) true를 반환해요. - isGenerated:
MethodNode를 인자로 받고, 그 메서드가 타입 검사 확장이newMethod메서드로 생성한 것인지 알려줘요. - isAnnotatedBy: AST 노드와 Class(또는 ClassNode)를 받아, 노드가 이 클래스로 어노테이션되었는지 알려줘요. 예:
isAnnotatedBy(node, NotNull) - getTargetMethod: 메서드 호출을 인자로 받고 타입 검사기가 결정한
MethodNode를 반환해요. - delegatesTo:
@DelegatesTo어노테이션의 동작을 흉내 내요. 인자가 특정 타입에 위임할 것이라고 알려줄 수 있어요(위임 전략도 지정할 수 있음).
7.2. 고급 타입 검사 확장
7.2.1. 사전 컴파일된 타입 검사 확장
위의 모든 예시는 타입 검사 스크립트를 사용해요. 그것들은 원본 형태로 classpath에서 발견되며, 즉:
- 타입 검사 확장에 해당하는 Groovy 소스 파일이 컴파일 classpath에 존재해요.
- 이 파일은 컴파일되는 각 소스 단위에 대해 Groovy 컴파일러가 컴파일해요(종종 소스 단위는 단일 파일에 해당).
이것은 타입 검사 확장을 개발하는 매우 편리한 방법이지만, 컴파일되는 각 파일에 대해 확장 자체를 컴파일해야 하므로 더 느린 컴파일 단계를 의미해요. 그런 이유로 사전 컴파일된 확장(precompiled extension)에 의존하는 것이 실용적일 수 있어요. 이를 위한 두 가지 옵션이 있어요:
- 확장을 Groovy로 작성하고 컴파일한 다음, 소스 대신 확장 클래스에 대한 참조를 사용해요.
- 확장을 Java로 작성하고 컴파일한 다음, 확장 클래스에 대한 참조를 사용해요.
Groovy로 타입 검사 확장을 작성하는 것이 가장 쉬운 길이에요. 기본적으로 아이디어는 타입 검사 확장 스크립트가 타입 검사 확장 클래스의 main 메서드 본문이 된다는 것이에요. 다음처럼요:
import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
class PrecompiledExtension extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL { (1)
@Override
Object run() { (2)
unresolvedVariable { var ->
if ('robot'==var.name) {
storeType(var, classNodeFor(Robot)) (3)
handled = true
}
}
}
}
(1) — TypeCheckingDSL 클래스를 확장하는 것이 가장 쉬워요
(2) — 그러면 확장 코드는 run 메서드 안으로 들어가야 해요
(3) — 그리고 소스 형태로 작성된 확장과 똑같은 이벤트를 사용할 수 있어요
확장 설정은 소스 형태 확장을 사용하는 것과 매우 유사해요:
config.addCompilationCustomizers(
new ASTTransformationCustomizer(
TypeChecked,
extensions:['typing.PrecompiledExtension'])
)
차이는 classpath의 경로를 사용하는 대신 사전 컴파일된 확장의 완전한 클래스 이름을 지정한다는 것이에요.
정말로 Java로 확장을 작성하고 싶다면 타입 검사 확장 DSL의 이점을 누릴 수 없어요. 위 확장은 Java로 이렇게 다시 쓸 수 있어요:
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.transform.stc.AbstractTypeCheckingExtension;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
public class PrecompiledJavaExtension extends AbstractTypeCheckingExtension { (1)
public PrecompiledJavaExtension(final StaticTypeCheckingVisitor typeCheckingVisitor) {
super(typeCheckingVisitor);
}
@Override
public boolean handleUnresolvedVariableExpression(final VariableExpression vexp) { (2)
if ("robot".equals(vexp.getName())) {
storeType(vexp, ClassHelper.make(Robot.class));
setHandled(true);
return true;
}
return false;
}
}
(1) — AbstractTypeCheckingExtension 클래스를 확장해요
(2) — 그런 다음 필요에 따라 handleXXX 메서드를 오버라이드해요
7.2.2. 타입 검사 확장에서 @Grab 사용하기
타입 검사 확장에서 @Grab 어노테이션을 사용하는 것은 전적으로 가능해요. 즉 컴파일 시점에만 사용할 수 있는 라이브러리를 포함시킬 수 있다는 뜻이에요. 그 경우 컴파일 시간이 크게 늘어난다는 것을 이해해야 해요(적어도 처음으로 의존성을 가져올 때는요).
7.2.3. 타입 검사 확장 공유 또는 패키징
타입 검사 확장은 classpath에 있어야 하는 스크립트일 뿐이에요. 그런 의미에서 그대로 공유하거나 classpath에 추가될 jar 파일에 번들할 수 있어요.
7.2.4. 전역 타입 검사 확장
컴파일러를 구성해 타입 검사 확장을 스크립트에 투명하게 추가할 수 있지만, 현재로서는 그것을 classpath에 두기만 해서는 확장을 투명하게 적용할 방법이 없어요.
7.2.5. 타입 검사 확장과 @CompileStatic
타입 검사 확장은 @TypeChecked와 함께 사용되지만 @CompileStatic과도 사용할 수 있어요. 하지만 다음을 알고 있어야 해요:
@CompileStatic과 사용되는 타입 검사 확장은 일반적으로 "안전하지 않은" 코드에서 정적으로 컴파일 가능한 코드를 생성하는 방법을 컴파일러가 알게 하기엔 충분하지 않아요.- 타입 검사 확장을
@CompileStatic과 함께 사용해 타입 검사만 향상시키는 것, 즉 동적 코드를 실제로 다루지 않고 더 많은 컴파일 오류를 도입하는 것은 가능해요.
첫 번째 요점을 설명하자면, 확장을 사용해도 컴파일러가 코드를 정적으로 컴파일하는 방법을 알지 못한다는 것이에요: 기술적으로, 예를 들어 타입 검사기에 동적 변수의 타입이 무엇인지 알려줘도 그것을 컴파일하는 방법을 알지 못해요. getBinding('foo')인가요, getProperty('foo')인가요, delegate.getFoo()인가요? 타입 검사 확장을 사용하더라도(그저 타입에 대한 힌트만 줄 뿐) 정적 컴파일러에게 그런 코드를 컴파일하는 방법을 직접 알려줄 방법이 전혀 없어요.
이 특정 예시의 가능한 해결책 하나는 컴파일러에게 혼합 모드 컴파일(mixed mode compilation)을 사용하도록 지시하는 것이에요. 더 고급 방법은 타입 검사 중에 AST 변환을 사용하는 것인데 훨씬 복잡해요.
타입 검사 확장은 타입 검사기가 실패하는 지점에서 도움을 주게 해주지만, 타입 검사기가 실패하지 않는 곳에서 실패하게도 해줘요. 이런 맥락에서 @CompileStatic에 대한 확장도 지원하는 것이 타당해요. SQL 쿼리를 타입 검사할 수 있는 확장을 상상해 보세요. 그 경우 확장은 동적·정적 컨텍스트 모두에서 유효할 거예요, 왜냐하면 확장이 없어도 코드는 여전히 통과할 테니까요.
7.2.6. 혼합 모드 컴파일(Mixed mode compilation)
이전 섹션에서 @CompileStatic으로 타입 검사 확장을 활성화할 수 있다는 사실을 강조했어요. 그 맥락에서 타입 검사기는 더 이상 해석되지 않는 변수나 알 수 없는 메서드 호출에 대해 불평하지 않지만, 여전히 그것들을 정적으로 컴파일하는 방법은 알지 못해요.
혼합 모드 컴파일은 세 번째 방법을 제공하는데, 해석되지 않은 변수나 메서드 호출이 발견될 때마다 동적 모드로 폴백하도록 컴파일러에 지시하는 것이에요. 이것은 타입 검사 확장과 특별한 makeDynamic 호출 덕분에 가능해요.
이를 설명하기 위해 Robot 예시로 돌아가 볼게요:
robot.move 100
그리고 @TypeChecked 대신 @CompileStatic을 사용해 타입 검사 확장을 활성화해 볼게요:
def config = new CompilerConfiguration()
config.addCompilationCustomizers(
new ASTTransformationCustomizer(
CompileStatic, (1)
extensions:['robotextension.groovy']) (2)
)
def shell = new GroovyShell(config)
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)
(1) — @CompileStatic을 투명하게 적용해요
(2) — 타입 검사 확장을 활성화해요
정적 컴파일러가 robot 변수의 타입에 대해 알게 되므로 move에 직접 호출을 할 수 있어서 스크립트가 잘 실행돼요. 하지만 그 전에 컴파일러는 robot 변수를 어떻게 얻는지 알았을까요? 실제로 기본적으로 타입 검사 확장에서 해석되지 않은 변수에 handled=true를 설정하면 자동으로 동적 해석을 촉발하므로, 이 경우 컴파일러가 혼합 모드를 사용하게 만들기 위해 특별히 할 일은 없어요. 하지만 로봇 스크립트에서 시작해 예시를 살짝 업데이트해 볼게요:
move 100
여기서 더 이상 robot에 대한 참조가 없다는 것을 알 수 있어요. move가 Robot 인스턴스에서 수행된다고 컴파일러에게 지시할 수 없으므로 우리 확장은 도움이 되지 않을 거예요. 이 코드 예시는 groovy.util.DelegatingScript의 도움으로 완전히 동적인 방식으로 실행될 수 있어요:
def config = new CompilerConfiguration()
config.scriptBaseClass = 'groovy.util.DelegatingScript' (1)
def shell = new GroovyShell(config)
def runner = shell.parse(script) (2)
runner.setDelegate(new Robot()) (3)
runner.run() (4)
(1) — 컴파일러를 구성해 기본 클래스로 DelegatingScript를 사용해요
(2) — 스크립트 소스를 파싱해야 하고 DelegatingScript의 인스턴스를 반환할 거예요
(3) — 그런 다음 setDelegate를 호출해 스크립트의 delegate로 Robot을 사용할 수 있어요
(4) — 그 다음 스크립트를 실행해요. move는 delegate에서 직접 실행돼요.
이것이 @CompileStatic으로 통과하길 원한다면 타입 검사 확장을 사용해야 하므로, 설정을 업데이트해 볼게요:
config.addCompilationCustomizers(
new ASTTransformationCustomizer(
CompileStatic, (1)
extensions:['robotextension2.groovy']) (2)
)
(1) — @CompileStatic을 투명하게 적용해요
(2) — move 호출을 인식하기 위한 대체 타입 검사 확장을 사용해요
그런 다음 이전 섹션에서 인식되지 않은 메서드 호출을 다루는 방법을 배웠으므로 이 확장을 작성할 수 있어요:
methodNotFound { receiver, name, argList, argTypes, call ->
if (isMethodCallExpression(call) (1)
&& call.implicitThis (2)
&& 'move'==name (3)
&& argTypes.length==1 (4)
&& argTypes[0] == classNodeFor(int) (5)
) {
handled = true (6)
newMethod('move', classNodeFor(Robot)) (7)
}
}
(1) — 호출이 메서드 호출이면(정적 메서드 호출이 아닌)
(2) — 이 호출이 "implicit this"(명시적 this. 없음)에서 이루어졌고
(3) — 호출되는 메서드가 move이고
(4) — 호출이 단일 인자로 이루어졌고
(5) — 인자가 int 타입이면
(6) — 타입 검사기에 호출이 유효하다고 알려주고
(7) — 호출의 반환 타입이 Robot이라고 알려주세요
이 코드를 실행하려 하면 실제로 런타임에 실패한다는 점에 놀랄 수도 있어요:
java.lang.NoSuchMethodError: java.lang.Object.move()Ltyping/Robot;
이유는 매우 간단해요: 타입 검사 확장은 정적 컴파일을 수반하지 않는 @TypeChecked에는 충분하지만, 추가 정보를 요구하는 @CompileStatic에는 충분하지 않아요. 이 경우 컴파일러에게 메서드가 존재한다고는 알려줬지만, 실제로 어떤 메서드인지, 메시지의 수신자(delegate)가 무엇인지는 설명하지 않았어요.
이것을 고치는 것은 매우 쉬우며 newMethod 호출을 다른 것으로 바꾸기만 하면 돼요:
methodNotFound { receiver, name, argList, argTypes, call ->
if (isMethodCallExpression(call)
&& call.implicitThis
&& 'move'==name
&& argTypes.length==1
&& argTypes[0] == classNodeFor(int)
) {
makeDynamic(call, classNodeFor(Robot)) (1)
}
}
(1) — 컴파일러에게 호출이 동적으로 이루어져야 한다고 알려줘요
makeDynamic 호출은 3가지를 해요:
newMethod처럼 가상 메서드를 반환해요.- 자동으로 handled 플래그를 true로 설정해요.
- 또한 호출이 동적으로 수행되도록 표시해요.
그래서 move 호출에 대한 바이트코드를 생성할 때가 되면, 이제 동적 호출로 표시되었으므로 동적 컴파일러로 폴백해 그 호출을 처리하게 해요. 그리고 확장이 동적 호출의 반환 타입이 Robot이라고 알려주므로, 후속 호출들은 정적으로 수행될 거예요!
어떤 사람들은 확장 없이 정적 컴파일러가 기본적으로 이것을 하지 않는 이유가 궁금할 수 있어요. 그것은 설계 결정이에요. 코드가 정적으로 컴파일되면 우리는 보통 컴파일 시점 타입 안전성과 최상의 성능을 원해요. 인식되지 않은 변수/메서드 호출이 자동으로 동적으로 만들어진다면 컴파일 시점 타입 안전성을 부분적으로 잃게 돼요. 오타와 잘못된 타이핑의 일부 시나리오가 더 이상 컴파일 시점에 잡히지 않게 될 거예요!
요약하면, 혼합 모드 컴파일을 원한다면 그것은 타입 검사 확장을 통해 명시적이어야 해요, 그래야 컴파일러와 DSL 설계자가 타이핑 규칙이 언제 완화되는지 정확히 알게 되기 때문이에요.
makeDynamic은 3가지 종류의 AST 노드에 사용할 수 있어요:
- 메서드 노드(
MethodNode) - 변수(
VariableExpression) - 프로퍼티 표현식(
PropertyExpression)
그것으로 충분하지 않다면, 정적 컴파일을 직접 할 수 없다는 뜻이며 AST 변환에 의존해야 해요.
7.2.7. 확장에서 AST 변환하기
타입 검사 확장은 AST 변환 설계 관점에서 매우 매력적으로 보여요: 확장은 추론된 타입 같은 컨텍스트에 접근할 수 있고, 이는 종종 있으면 좋은 것이에요. 그리고 확장은 추상 구문 트리에 직접 접근할 수 있어요. AST에 접근할 수 있으므로 이론상 AST를 수정하지 못할 이유는 없어요. 하지만 고급 AST 변환 설계자이고 컴파일러 내부를 잘 알고 있지 않다면 그렇게 하지 않는 것을 권장해요:
- 우선 타입 검사의 계약, 즉 AST에 어노테이션을 붙이고 어노테이션만 붙이는 계약을 명시적으로 깨뜨리게 돼요. 타입 검사는 AST 트리를 수정해서는 안 돼요, 왜냐하면
@TypeChecked어노테이션이 없는 코드가 어노테이션 없이도 같게 동작한다는 것을 더 이상 보장할 수 없게 되기 때문이에요. - 확장이
@CompileStatic과 함께 작동하도록 의도되었다면 AST를 수정할 수 있어요, 왜냐하면 실제로@CompileStatic이 결국 하는 것이 그렇기 때문이에요. 정적 컴파일은 동적 Groovy에서 같은 시맨틱을 보장하지 않으므로@CompileStatic으로 컴파일된 코드와@TypeChecked로 컴파일된 코드 사이에는 실제로 차이가 있어요. AST를 업데이트하기 위해 어떤 전략을 고를지는 여러분의 몫이지만, 아마 타입 검사 전에 실행되는 AST 변환을 사용하는 것이 더 쉬울 거예요. - 타입 검사기 전에 촉발되는 변환에 의존할 수 없다면 매우 조심해야 해요.
타입 검사 단계는 바이트코드 생성 전에 컴파일러에서 실행되는 마지막 단계입니다. 다른 모든 AST 변환은 그 전에 실행되며, 컴파일러는 타입 검사 단계 전에 생성된 잘못된 AST를 "고치는" 일을 아주 잘 해요. 타입 검사 중에 변환을 수행하는 순간, 예를 들어 타입 검사 확장에서 직접 수행하면 100% 컴파일러 준수 추상 구문 트리를 생성하는 모든 작업을 스스로 해야 하며, 이는 쉽게 복잡해질 수 있어요. 그렇기 때문에 타입 검사 확장과 AST 변환을 시작하는 단계라면 그 길로 가지 않는 것을 권장합니다.
7.2.8. 예시
실생활의 타입 검사 확장 예시는 찾기 쉬워요. Groovy 소스 코드를 다운로드하고 다양한 확장 스크립트와 연결된 TypeCheckingExtensionsTest 클래스를 살펴보면 돼요.
복잡한 타입 검사 확장의 예시는 Markup Template Engine 소스 코드에서 찾을 수 있어요: 이 템플릿 엔진은 타임 검사 확장과 AST 변환에 의존해 템플릿을 완전히 정적으로 컴파일된 코드로 변환해요. 이에 대한 소스는 여기에서 찾을 수 있어요.
더 알아보기
- Groovy 클로저 — 클로저의 동작과 위임 전략을 더 깊이 살펴볼 수 있어요.
- Groovy 연산자 — 산술·비교·논리 연산자와 연산자 오버로딩을 다뤄요.
- Groovy 객체 지향 — 클래스, 트레이트, 타입을 다뤄요.
- Groovy 프로그램 구조 — 패키지, import, 스크립트 구조를 다뤄요.