프로그래밍 방식 구조 타입
프로그래밍 방식 구조 타입 (Programmatic Structural Types)
데이터베이스 접근을 모델링하는 같은 쓰임새는 정적 타입 언어에서 동적 타입 언어보다 더 불편하고 어색해요. 동적 타입 언어에서는 행(row)을 레코드나 객체로 모델링하고, 단순한 점 표기법으로 항목을 선택하는 것이 아주 자연스러워요 (예: row.columnName). 정적 타입 언어에서 같은 경험을 얻으려면 데이터베이스 조작에서 나올 수 있는 모든 행(조인과 프로젝션에서 나오는 행 포함)마다 클래스를 정의하고, 행과 그 클래스 사이의 매핑 방식을 마련해야 해요. 이건 보일러플레이트가 아주 많아서, 개발자들이 정적 타입의 장점을 버리고 컬럼 이름을 문자열로 다루어 다른 연산자에 전달하는 단순한 방식을 택하게 되죠 (예: row.select("columnName")). 그런 방식은 정적 타입의 장점을 포기한 채 동적 타입 버전만큼 자연스럽지도 못해요.
구조 타입(structural type)은 동적 맥락에서 단순한 점 표기법을 지원하면서도 정적 타입의 장점을 잃지 않으려는 상황에 도움이 돼요. 개발자가 점 표기법을 쓰면서 필드와 메서드를 어떻게 해석할지 구성할 수 있게 해 주거든요.
본문
예시 (Example)
구조 타입 Person의 예시를 볼게요.
type Person = Record { val name: String; val age: Int }
타입 Person은 부모 타입 Record에 name과 age 두 필드를 정의하는 리파인먼트(refinement)를 추가해요. name과 age는 부모 타입에 정의되어 있지 않기 때문에 이 리파인먼트를 **구조적(structural)**이라고 불러요. 그래도 그것들은 타입 Person의 멤버로 존재해요.
이 덕분에 접근이 유효한지 컴파일 타임에 검사할 수 있어요.
val person: Person = ???
println(s"${person.name} is ${person.age} years old.") // works
println(person.email) // error: value email is not a member of Person
Record는 어떻게 정의하고, person.name은 어떻게 풀어낼까요?
Record는 마커 트레이트 scala.Selectable을 상속받는 클래스로, 필드 이름을 그 값에 매핑하는 selectDynamic 메서드를 정의해요. 구조 타입의 멤버를 선택하는 것은 이 메서드를 호출하는 문법적 설탕(syntactic sugar)이에요. person.name과 person.age 선택은 스칼라 컴파일러에 의해 다음과 같이 변환돼요.
person.selectDynamic("name").asInstanceOf[String]
person.selectDynamic("age").asInstanceOf[Int]
예를 들어 Record는 다음과 같이 정의할 수 있어요.
class Record(elems: (String, Any)*) extends Selectable:
private val fields = elems.toMap
def selectDynamic(name: String): Any = fields(name)
이렇게 하면 Person의 인스턴스를 아래처럼 만들 수 있어요.
val person = Record("name" -> "Emma", "age" -> 42).asInstanceOf[Person]
이 예시에서 부모 타입 Record는 elems 인자에 임의의 레코드를 표현할 수 있는 제네릭 클래스예요. elems 인자는 String 타입의 라벨과 Any 타입의 값의 쌍으로 이뤄진 시퀀스예요. Person을 Record로 만들 때 레코드가 올바른 타입의 올바른 필드를 정의하도록 타입 캐스트로 단언해야 해요. Record 자체는 너무 약하게 타입되어 있어서 사용자 도움 없이는 컴파일러가 그걸 알 수 없거든요. 실제로는 구조 타입과 그 밑바탕의 제네릭 표현 사이의 연결을 데이터베이스 레이어가 해 주는 경우가 대부분이라, 최종 사용자가 신경 쓸 일은 아니에요.
selectDynamic 외에도 Selectable 클래스가 applyDynamic 메서드를 정의하는 경우가 있어요. 이건 구조 멤버에 대한 함수 호출을 변환할 때 쓰여요. 그래서 a가 Selectable의 인스턴스라면, 구조 호출 a.f(b, c)는 다음과 같이 변환돼요.
a.applyDynamic("f")(b, c)
Java 리플렉션 사용하기 (Using Java Reflection)
Selectable과 Java 리플렉션을 쓰면 서로 무관한 클래스에서 멤버를 선택할 수 있어요.
Java 리플렉션으로 구조 호출을 쓰기 전에 대안을 먼저 고려해야 해요. 예를 들어 타입 클래스를 쓰면 더 모듈화되고 효율적인 구조를 얻을 수 있는 경우도 있어요.
예를 들어 FileInputStream과 Channel 클래스 양쪽에 close 메서드를 호출하는 동작을 제공하고 싶다고 해볼게요. 그런데 이 클래스들은 서로 무관해서 close 메서드를 가진 공통 슈퍼타입이 없어요. 그래서 아래에서 close 메서드를 정의하는 구조 타입 Closeable을 정의해요.
type Closeable = { def close(): Unit }
class FileInputStream:
def close(): Unit
class Channel:
def close(): Unit
이상적으로는 두 클래스 모두에 close 메서드를 정의하는 공통 인터페이스를 추가하고 싶지만, 두 클래스는 우리가 제어할 수 없는 라이브러리에 정의되어 있어요. 타협안으로 구조 타입을 써서 autoClose 메서드 하나를 단일 구현으로 정의할 수 있어요.
import scala.reflect.Selectable.reflectiveSelectable
def autoClose(f: Closeable)(op: Closeable => Unit): Unit =
try op(f) finally f.close()
호출 f.close()는 리시버 f에서 close 메서드를 식별하고 호출하려면 Closeable이 Selectable을 상속해야 해요. Selectable로의 보편적인 암시적 변환(universal implicit conversion)은 위에서 보여준 reflectiveSelectable을 import함으로써 활성화되는데, 이건 Java 리렉션에 기반하고 있어요. 그러면 "내부적으로"는 다음이 일어나요.
- 암시적 변환이
f를scala.reflect.Selectable(즉Selectable의 하위 타입)의 인스턴스로 감싸요. - 컴파일러가 감싸진
f에 대한close호출을applyDynamic호출로 변환해요. 최종 결과는 다음과 같아요.
reflectiveSelectable(f).applyDynamic("close")()
reflectiveSelectable의 결과에 있는 applyDynamic 구현은 런타임에 f가 참조하는 값에서 파라미터가 없는 close 메서드를 Java 리플렉션으로 찾아 호출해요.
이런 구조 호출은 일반 메서드 호출보다 훨씬 느린 경향이 있어요. reflectiveSelectable을 반드시 import해야 하는 것은, 비효율적인 일이 일어나고 있다는 표지판을 세워 두는 역할을 해요.
참고로 스칼라 2에서는 Java 리플렉션이 구조 타입에 유일하게 쓰이는 메커니즘이고, reflectiveSelectable 변환 없이도 자동으로 활성화돼요. 하지만 비효율적인 디스패치를 경고하기 위해 스칼라 2는 언어 import인 import scala.language.reflectiveCalls를 요구해요.
확장성 (Extensibility)
Java 리플렉션 외의 다른 접근 방식을 지원하도록 Selectable의 새 인스턴스를 정의할 수 있어요. 그러면 이 문서 처음에 든 데이터베이스 접근 예시 같은 용법을 가능하게 해 주죠.
지역 Selectable 인스턴스 (Local Selectable Instances)
Selectable을 상속하는 지역 및 익명 클래스는 다른 클래스보다 더 리파인된(refined) 타입을 얻어요. 예시를 볼게요.
trait Vehicle extends reflect.Selectable:
val wheels: Int
val i3 = new Vehicle: // i3: Vehicle { val range: Int }
val wheels = 4
val range = 240
i3.range
이 예시에서 i3의 타입은 Vehicle { val range: Int }예요. 따라서 i3.range는 정상적인(well-formed) 표현이에요. 기본 클래스 Vehicle에는 range 필드나 메서드가 정의되어 있지 않으니, i3을 초기화하는 익명 클래스의 range 필드에 접근하려면 구조 디스패치가 필요해요. 구조 디스패치는 Vehicle의 기본 트레이트인 reflect.Selectable이 구현하는데, 필요한 selectDynamic 멤버를 정의하고 있어요.
Vehicle이 selectDynamic과 applyDynamic을 다르게 구현하는 scala.Selectable의 다른 하위 클래스를 상속할 수도 있어요. 하지만 Selectable을 전혀 상속하지 않으면, 코드가 더 이상 타입 검사를 통과하지 못해요.
trait Vehicle:
val wheels: Int
val i3 = new Vehicle: // i3: Vehicle
val wheels = 4
val range = 240
i3.range // error: range is not a member of `Vehicle`
차이는 이러해요. Selectable을 상속하지 않는 익명 클래스의 타입은 클래스의 부모 타입(들)만으로 이뤄지고 리파인먼트가 더해지지 않아요. 그래서 i3은 이제 그냥 Vehicle 타입이고, i3.range 선택은 "member not found" 오류를 내죠.
참고로 스칼라 2에서는 모든 지역 및 익명 클래스가 리파인된 타입의 값을 만들어낼 수 있었어요. 하지만 그런 리파인먼트로 정의된 멤버는 언어 import reflectiveCalls가 있어야만 선택할 수 있었어요.
scala.Dynamic과의 관계 (Relation with scala.Dynamic)
여기에 분명 scala.Dynamic과의 연관성이 있어요. 둘 다 멤버를 프로그래밍 방식으로 선택하거든요. 하지만 차이점도 있어요.
- 완전한 동적 선택은 타입 안전하지 않지만, 구조적 선택은 구조 타입과 밑바탕 값 사이의 대응이 명시된 대로인 한 타입 안전해요.
- 두 접근 방식 모두
selectDynamic과applyDynamic이라는 두 접근 연산을 공유해요.Selectable에서applyDynamic은 메서드의 형식 파라미터 타입을 나타내는java.lang.Class인자를 추가로 받을 수 있어요. updateDynamic은Dynamic에만 고유한데, 앞서 말했듯 이 사실은 바뀔 수 있어서 가정으로 삼으면 안 돼요.
더 자세한 내용은 별도 문서를 참고하세요.