스칼라 매크로와 준따옴표로 상용구 코드 줄이기

게시 됨: 2022-03-11

Scala 언어는 개발자에게 깨끗하고 간결한 구문으로 객체 지향 및 기능 코드를 작성할 수 있는 기회를 제공합니다(예: Java와 비교). 케이스 클래스, 고차 함수 및 유형 추론은 Scala 개발자가 유지 관리가 더 쉽고 오류가 발생하기 쉬운 코드를 작성하는 데 활용할 수 있는 기능 중 일부입니다.

불행히도 스칼라 코드는 상용구에 영향을 받지 않으며 개발자는 이러한 코드를 리팩토링하고 재사용하는 방법을 찾는 데 어려움을 겪을 수 있습니다. 예를 들어, 일부 라이브러리는 개발자가 봉인된 클래스의 각 하위 클래스에 대해 API를 호출하여 반복하도록 합니다.

그러나 이는 개발자가 매크로와 준따옴표를 활용하여 컴파일 시간에 반복되는 코드를 생성하는 방법을 배우기 전까지만 해당됩니다.

사용 사례: 상위 클래스의 모든 하위 유형에 대해 동일한 처리기 등록

마이크로 서비스 시스템을 개발하는 동안 특정 클래스에서 파생된 모든 이벤트에 대해 단일 처리기를 등록하고 싶었습니다. 내가 사용하고 있던 프레임워크의 세부 사항으로 인해 주의가 산만해지는 것을 방지하기 위해 다음은 이벤트 핸들러 등록을 위한 API의 단순화된 정의입니다.

 trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) }

모든 Event 유형에 대한 이벤트 프로세서가 있으면 addHandler 메소드를 사용하여 Event 의 하위 클래스에 대한 핸들러를 등록할 수 있습니다.

위의 서명을 보면 개발자는 주어진 유형에 대해 등록된 핸들러가 해당 하위 유형의 이벤트에 대해 호출될 것으로 예상할 수 있습니다. 예를 들어 User 엔터티 수명 주기와 관련된 이벤트의 다음 클래스 계층을 고려해 보겠습니다.

UserEvent에서 파생된 Scala 이벤트의 계층 구조입니다. 세 가지 직계 자손이 있습니다: UserCreated(이름과 이메일이 모두 문자열임), UserChanged 및 UserDeleted. 게다가 UserChanged는 NameChanged(문자열인 이름을 가짐)와 EmailChanged(문자열인 이메일을 가짐)의 두 가지 하위 항목을 가지고 있습니다.
Scala 이벤트 클래스 계층.

해당 Scala 선언은 다음과 같습니다.

 sealed trait UserEvent final case class UserCreated(name: String, email: String) extends UserEvent sealed trait UserChanged extends UserEvent final case class NameChanged(name: String) extends UserChanged final case class EmailChanged(email: String) extends UserChanged case object UserDeleted extends UserEvent

각각의 특정 이벤트 클래스에 대한 핸들러를 등록할 수 있습니다. 하지만 모든 이벤트 클래스에 대한 핸들러를 등록하려면 어떻게 해야 할까요? 내 첫 번째 시도는 UserEvent 클래스에 대한 핸들러를 등록하는 것이었습니다. 모든 이벤트에 대해 호출될 것으로 예상했습니다.

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)

테스트 중에 핸들러가 호출되지 않은 것으로 나타났습니다. 제가 사용하고 있는 프레임워크인 Lagom의 코드를 파헤쳤습니다.

이벤트 프로세서 구현이 등록된 클래스를 키로 사용하여 맵에 핸들러를 저장했음을 발견했습니다. 이벤트가 발생하면 호출할 핸들러를 얻기 위해 해당 맵에서 해당 클래스를 찾습니다. 이벤트 프로세서는 다음 라인을 따라 구현됩니다.

 type Handler[Event] = (_ <: Event) => Unit private case class EventProcessorImpl[Event]( handlers: Map[Class[_ <: Event], List[Handler[Event]]] = Map[Class[_ <: Event], List[Handler[Event]]]() ) extends EventProcessor[Event] { override def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] = { val eventClass = implicitly[ClassTag[E]].runtimeClass.asInstanceOf[Class[_ <: Event]] val eventHandlers = handler .asInstanceOf[Handler[Event]] :: handlers.getOrElse(eventClass, List()) copy(handlers + (eventClass -> eventHandlers)) } override def process(event: Event): Unit = { handlers .get(event.getClass) .foreach(_.foreach(_.asInstanceOf[Event => Unit].apply(event))) } }

위에서 UserEvent 클래스에 대한 처리기를 등록했지만 UserCreated 와 같은 파생 이벤트가 발생할 때마다 프로세서는 레지스트리에서 해당 클래스를 찾지 못했습니다.

따라서 상용구 코드 시작

해결책은 각각의 구체적인 이벤트 클래스에 대해 동일한 핸들러를 등록하는 것입니다. 다음과 같이 할 수 있습니다.

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)

이제 코드가 작동합니다! 하지만 반복적입니다.

또한 새로운 이벤트 유형을 도입할 때마다 수정해야 하므로 유지 관리가 어렵습니다. 또한 코드베이스에 모든 구체적인 유형을 나열해야 하는 다른 위치가 있을 수도 있습니다. 우리는 또한 해당 장소를 수정해야 합니다.

이는 UserEvent 가 봉인된 클래스이기 때문에 실망스럽습니다. 즉, 모든 직접 하위 클래스가 컴파일 시간에 알려집니다. 상용구를 피하기 위해 해당 정보를 활용할 수 있다면 어떨까요?

구조를 위한 매크로

일반적으로 스칼라 함수는 런타임에 전달한 매개변수를 기반으로 값을 반환합니다. Scala 매크로는 호출을 대체하기 위해 컴파일 타임에 일부 코드를 생성하는 특수 함수로 생각할 수 있습니다.

macro 인터페이스는 값을 매개변수로 사용하는 것처럼 보이지만 실제로는 컴파일러가 사용하는 소스 코드 구조의 내부 표현인 추상 구문 트리(AST)에서 해당 매개변수를 캡처합니다. 그런 다음 AST를 사용하여 새 AST를 생성합니다. 마지막으로, 새로운 AST는 컴파일 타임에 매크로 호출을 대체합니다.

주어진 클래스의 알려진 모든 하위 클래스에 대한 이벤트 핸들러 등록을 생성하는 macro 선언을 살펴보겠습니다.

 def addHandlers[Event]( processor: EventProcessor[Event], handler: Event => Unit ): EventProcessor[Event] = macro setEventHandlers_impl[Event] def setEventHandlers_impl[Event: c.WeakTypeTag](c: Context)( processor: c.Expr[EventProcessor[Event]], handler: c.Expr[Event => Unit] ): c.Expr[EventProcessor[Event]] = { // implementation here }

각 매개변수(유형 매개변수 및 반환 유형 포함)에 대해 구현 메소드에는 해당 AST 표현식이 매개변수로 있습니다. 예를 들어 c.Expr[EventProcessor[Event]]EventProcessor[Event] ] 와 일치합니다. 매개변수 c: Context 는 컴파일 컨텍스트를 래핑합니다. 컴파일 타임에 사용 가능한 모든 정보를 얻는 데 사용할 수 있습니다.

우리의 경우 봉인된 클래스의 자식을 검색하고 싶습니다.

 import c.universe._ val symbol = weakTypeOf[Event].typeSymbol def subclasses(symbol: Symbol): List[Symbol] = { val children = symbol.asClass.knownDirectSubclasses.toList symbol :: children.flatMap(subclasses(_)) } val children = subclasses(symbol)

간접 서브클래스도 처리되도록 하기 위해 subclasses 메소드에 대한 재귀 호출에 유의하십시오.

등록할 이벤트 클래스 목록이 있으므로 Scala 매크로가 생성할 코드에 대한 AST를 빌드할 수 있습니다.

스칼라 코드 생성: AST 또는 준따옴표?

AST를 구축하기 위해 AST 클래스를 조작하거나 Scala quasiquotes를 사용할 수 있습니다. AST 클래스를 사용하면 읽고 유지 관리하기 어려운 코드가 생성될 수 있습니다. 대조적으로, 준따옴표는 생성된 코드와 매우 유사한 구문을 사용할 수 있게 하여 코드의 복잡성을 크게 줄입니다.

단순 이득을 설명하기 위해 간단한 표현식 a + 2 를 사용하겠습니다. AST 클래스로 이것을 생성하는 것은 다음과 같습니다.

 val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))

보다 간결하고 읽기 쉬운 구문을 사용하여 준따옴표로 동일한 결과를 얻을 수 있습니다.

 val exp = q"a + 2"

매크로를 간단하게 유지하기 위해 준따옴표를 사용합니다.

AST를 만들고 매크로 함수의 결과로 반환해 보겠습니다.

 val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls)

위의 코드는 매개변수로 받은 프로세서 표현식으로 시작하며, 각 Event 서브클래스에 대해 서브클래스와 핸들러 함수를 매개변수로 하여 addHandler 메소드에 대한 호출을 생성합니다.

이제 UserEvent 클래스에서 매크로를 호출할 수 있으며 모든 하위 클래스에 대한 처리기를 등록하는 코드를 생성합니다.

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)

그러면 다음 코드가 생성됩니다.

 com.example.event.processor.EventProcessor .apply[com.example.event.handler.UserEvent]() .addHandler[UserEvent](handler) .addHandler[UserCreated](handler) .addHandler[UserChanged](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted](handler)

전체 프로젝트의 코드가 올바르게 컴파일되고 테스트 케이스는 핸들러가 실제로 UserEvent 의 각 하위 클래스에 대해 등록되었음을 보여줍니다. 이제 새로운 이벤트 유형을 처리하는 코드의 용량에 대해 더 확신할 수 있습니다.

반복 코드? Scala 매크로를 사용하여 작성

Scala는 일반적으로 상용구를 피하는 데 도움이 되는 간결한 구문을 가지고 있지만 개발자는 여전히 코드가 반복적이어서 재사용을 위해 쉽게 리팩토링할 수 없는 상황을 찾을 수 있습니다. 스칼라 매크로를 준따옴표와 함께 사용하여 이러한 문제를 극복하고 스칼라 코드를 깨끗하고 유지 관리할 수 있게 유지할 수 있습니다.

개발자가 코드를 생성할 수 있도록 Scala 매크로를 활용하는 Macwire와 같은 인기 있는 라이브러리도 있습니다. 나는 모든 Scala 개발자가 이 언어 기능에 대해 더 많이 배울 것을 강력히 권장합니다. 이 기능은 도구 세트의 귀중한 자산이 될 수 있기 때문입니다.