출처: https://zenn.dev/nuits_jp/articles/2021-02-20-cs-metaprogramming-orverview-in-2021
샘플 코드는 이하에 모두 공개하고 있다.
https://github.com/nuitsjp/MetaprogrammingOverviewIn2021
Introduction
C#9.0의 릴리스와 아울러, Source Generator가 릴리스 되었다.
이 절에서는 C#에서 사용할 수 있는 대표적인 메타 프로그래밍 기술에 대해
에 대해서 정리해 보았다.
Attention!
본고는 가능한 한 조사한 후에 기재한 것이지만, 누락·누설·오류가 포함될지도 모르고, 어쩌면 주관이 포함되어 있을 것이다.
What is metaprogramming?
우선은 「메타프로그래밍이란 무엇인가?」를 간단하게 설명하려고 한다.
Wikipedia에는 아래와 같이 설명되어 있다.
메타프로그래밍(metaprogramming)이란 프로그래밍 기법의 일종으로, 로직을 직접 코딩하는 것이 아니라, 어느 패턴을 가지는 로직을 생성하는 고위 로직에 의해서 프로그래밍을 하는하는 방법, 또 이 고위 로직을 정의하는 방법이다.
예를 들어, 데이터베이스 구조에서 클래스를 생성하거나 클래스를 구조 분석하고 JSON으로 직렬화하거나..
메타데이터를 바탕으로 프로그램을 생성하는 프로그래밍을 메타프로그래밍이라고 한다.
C#에서 사용할 수 있는 대표적인 메타프로그래밍 기술에는 아래와 같은 것이 있다.
이번에는 아래 다섯 가지를 다룬다.
What should be used and when?
여러 가지 메타 프로그래밍 기술을 들었는데 언제 무엇을 사용해야할까?
이것을 생각할 때, 아래 라인으로 단락지어 생각하면 알기 쉽다.
동적 메타 프로그래밍
항목 내용
실현 기술 Reflection, Expression Tree
구체적인 예 Dapper, JSON⇔ 오브젝트 변환 라이브러리 등
이용 타이밍 런타임
예를 들어 Dapper 등이 런타임 시 SQL의 실행 결과를 객체에 붙이거나 그 반대를 실현하기 위해 동적으로 구조 해석하여 멤버를 가져오거나 설정하는데 자주 이용되는 기술이다.
런타임 매개 변수에 따라 메타 프로그래밍 동작을 변경하려는 경우 여기에서 구현해야한다.
예를 들어 OR Mapper에서 검색 화면의 입력 값에 따라 동적 SQL을 생성하고 싶다면 여기를 런타임에 해결해야 할 것이다.
정적 메타 프로그래밍
항목 내용
실현 기술 T4 Template, Source Generator, Fody/Mono.Cecil
구체적인 예 PropertyChanged.Fody, UnitGenerator
이용 타이밍 런타임
이쪽은 전자와 같이 한정되지 않고, 사용 용도가 매우 넓다.
현 시점에서는, 정형적 구현 즉 보일러 코드의 자동 생성에 이용되고 있는 것을 자주 볼 수 있다.
예를 들어 WPF에 대한 INotifyPropertyChanged 구현을 자동으로 생성하거나 DDD를 위한 값 개체의 일반적인 구현을 제공한다.
실행 전에 해결되므로 동작이 매우 가볍다.
향후는 Dapper 같은 라이브러리가 스스로 이용하는 코드를 위해서 Source Generator를 이용하는 경우가 늘 수도 있다.
Implementation example
이제 실제 샘플 코드를 살펴보겠다.
이번에는 Equals의 재정의를 지원하는 라이브러리를 주제로 한다.
Identifier 속성을 선언된 속성으로 등치 비교한다.
아래와 같은 코드이다.
public class Customer |
Equals에 필요한 코드를 모두 구현하면 상당한 볼륨이 되므로, 아래와 같은 제약을 전제로 한다.
자세한 내용은 GitHub 에 공개하고 있으므로 보기 바란다.
벤치마크
또한 먼저 각 구현 코드의 벤치 마크 값을 게재한다.
Reflection
솔루션을 열면 이런 식으로 구현 기술마다 폴더로 나누어져 있다.
상단은 Not 메타 프로그래밍 코드이다. Reflection은 두 번째이다.
열면 세 가지 프로젝트가 있다. 대체로, 구현 기술도 같은 구성이다.
우선 구현 기술 명의 프로젝트가 있고, 이쪽에 구문 분석되는 측의 코드가 들어가 있다.
Customer와 Employee가 있다.
아래는 Customer 코드이다.
using Commons; |
Equals를 오버라이드(override)하고, 내부로부터 Equals 클래스의 Invoke에 처리를 위양하고 있다. 이 구현은 Metaprogramming과 관련된 프로젝트에 포함되어 있다.
using System.Linq; |
Type에서 Identifier 속성을 가지는 프로퍼티의 PropertyInfo 를 취득하고 있는 것을 볼 수 있다. 그리고 PropertyInfo를 사용하여 값 비교할 속성 값을 가져온다.
Reflection은 구현이 간단하지만 반면 동작 속도가 느리다. 때문에 한번만 실행하면 좋은 자동화 툴의 작성 때는 사용이 편리하다.
얼마나 느린가 하면, 앞의 표에 있는 것처럼, 비 메타프로그래밍으로 2.798 ns 라면, Reflections에서는 2,891.742 ns의 약 1000배이다.
하지만, 단위 나노세컨드이므로, 사용할 수 없을 정도의 느림은 아니다.
다만 이것은 개선의 여지가 있고, 구조 해석의 코드를 캐싱하는 것으로 성능을 개선할 수 있다.
실제로 코드를 살펴 보겠다.
using System.Linq; |
Equals의 Invoke 메서드는 내부적으로 캐시된 Instance 객체의 InvokeInner 메서드를 호출한다. 그 중에는 이전의 PropertyInfo를 얻는 코드가 포함되어 있지 않는다.
Instance 개체를 한 번만 인스턴스화할 때 PropertyInfo를 캐시한다. 이런 식으로 캐시를 사용한다.
이제 약 10분의 1까지 줄일 수 있다. 이것은 반복할수록 차이가 커진다.
Expression Tree
실제로 동적 메타 프로그래밍에서는 이것이 사용되는 경우가 매우 많다.
Customer 코드는 Reflection과 완전히 동일하며 Equals<T> 구현이 다르다.
public static bool Invoke(T self, object other) |
Expression 이라고 하는 것을 조립하고, 마지막으로 Compile 해서 람다를 생성하고, 이 람다를 값 취득으로 이용한다.
그런데, 이 실행 속도가 무려 85,835.928 ns 나 걸린다. 컴파일이 무겁다는 것이다.
이는 Reflection과 마찬가지로 컴파일 결과를 캐싱 하여 개선할 수 있다.
캐시 후에는 무려 7.914ns가 된다. 비 메타프로그래밍 코드가 2.798 ns로, 단위가 나노 세컨드이기 때문에, 동등 레벨이라고 할 수 있을 정도이다.
이러한 특징이 있기 때문에, 타입 변환을 수반하는 ORM이나 통신계의 라이브러리와 궁합이 매우 좋고, 실제로 잘 이용되고 있다.
T4 Template
T4만, Metaprogramming로 붙는 프로젝트가 없다.
이는 Customer 코드와 동일한 프로젝트에 tt 파일을 배치해야하기 때문이다.
내용을 보자.
ASP.NET의 Razer에도, 일반 템플릿 구문 파일이다.
자세한 내용은 생략하지만 코드 중에 EnvDTE.DTE 라는 클래스를 볼 수 있을것이다. 이것은 Visual Studio를 나타내는 객체로, 거기로부터 포함되는 코드의 메타 정보를 취득한다.
tt 파일 아래에 Equals 코드가 생성된다.
이것을 실행하면 2.251 ns라는 결과가 되었다.
비 메타 구현과 완전히 동등하다.
다만, T4에는 여러가지 습관이 있어서…;;;
우선, 코드 생성하는 대상 코드가 증감해도, 자동적으로 추종해 주지 않는 문제도 있다.
T4에서 생성된 코드는 T4 파일을 저장하거나 메뉴에서 명시적으로 실행될 때이다. 이 때문에 예를 들어, 새롭게 Item 클래스를 작성해도, 명시적으로 생성하지 않는한 코드 생성이 되지 않는다.
또 삭제 시에는, 한번 cs 파일안을 비우고 나서 실행하지 않으면 에러가 된다.
이 문제는, 재배포 해서 이용하는 경우에, 이용자가 이 특징을 이해하고 사용하지 않으면 곤란할 수 있다.
또 IDE 의존이므로 CI/CD 시에 생성이라고 하는 것이 어렵다.
그렇지만, 복잡한 코드 생성도, 굉장히 간단하게 할 수 있다는 것이 T4의 강점이다.
Source Generator와 비교했을 때, 각각에 적합하지 않는 것이 있으므로, Source Generator를 소개한 후에 좀 더 보충한다.
Source Generator
소스 생성은 메타 프로그램 측 프로젝트의 SourceGenerator.cs에서 소스를 생성한다.
실제로 코드 생성을 하고 있는 코드가 이쪽이다.
private string GenerateSource(EqualsTemplate equalsTemplate) |
StringBuilder에 Append, Append 한다.
이 코드는 구현 시 자주 호출되므로 문자열 인터폴레이션($"public partial class {equalsTemplate.Namespace}"과 같은 쓰기)이라면 성능에 문제가 있는 탓이다.
하지만 이것이 귀찮아? 라는 것에서 T4 템플릿의 차례이다.
앞의 예에서는 파일 속성의 "사용자 정의 도구"란이 "TextTemplatingFileGenerator"로 설정되어 있다고 생각하지만 이것을 "TextTemplatingFilePreprocessor"로 변경한다.
T4 템플릿을 사용하면 아래와 같이 작성할 수 있다.
직관적으로 보기 쉽고, 쓰기 쉽다.
여기에서 아래와 같이 소스를 생성하는 코드를 자동 생성할 수 있다.
public partial class EqualsTemplate : EqualsTemplateBase |
이 T4 파일은 어디까지나 소스 생성 시에 이용되고 사용자측에는 배포되지 않기 때문에 단점이 매우 작다.
Source Generator의 대단한 점은
"코드는 메모리에서 생성되고 파일은 생성되지 않지만 생성 코드를 참조할 수 있고 디버그도 할 수 있다"
이다.
또한 실행 속도도 2.259ns로, 당연히 비 메타프로그래밍 버전과 동등하다.
이와 같이, Source Generator는 이용자에게 특별한 이해를 요구할 필요가 없고, 재배포하기 쉽다는 것이 T4와 비교 시 큰 이점이다.
이 때문에, 향후는 Dapper와 같은 라이브러리에서도 타입 변환 코드의 생성을 동적으로 실행하는 것이 아니라, 사전에 소스 생성해 두고, 이용하는 형태가 될지도 모른다. 빠르다.
또한 Analyzer와의 궁합이 좋기 때문에 사용자 친화적인 라이브러리를 만들기 쉬운 것도 특징이다.
예를 들어, Identifier 속성은 하나의 클래스로 제한 되었다. 그러나 사용자가 실수로 2개로 하면, 제대로 에러를 내게 하는 것을 간단하게 할 수 있다.
IDE 독립적이므로 CI/CD도 하기 쉽다는 특징이 있다.
Fody
마지막으로 Fody이다.
Fody는 C # 코드를 생성하는 것이 아닌 빌드 된 후에 DLL을 직접 수정한다. 즉 IL을 따른다.
// Foo foo = other as Foo; |
주석의 C# 코드와 그 아래의 IL 생성 코드는 동일하다. C#er서도 IL를 모르면 한 읽을 수 없다…;;;
하지만 실행 속도는 2.584ns로 매우 빠르다. 비 메타 프로그래밍과의 차이는 측정 오차이다.
Fody의 가장 큰 장점은 다른 코드와 달리 기존 코드를 변경할 수 있다는 것이다.
그 때문에, AOP하고 싶은 경우 Source Generator 따위는 실현할 수 없었던 것을 할 수 있다. 예를 들어, 기존 코드에 트래킹 코드나 리트라이 구현을 임베드하는 것이다.
그러나 다른 수단으로 대체할 수 있는 것은 다른 것을 이용하는 편이 좋다.
우선 IL을 기억해야 하는 것으로, 유사성이 없는 새로운 언어를 하나 기억할 필요가 있고, 같은 구현으로 해도, 몇배나 코드를 써야 한다. 필요한 테스트 코드도 부풀어 오른다.
이것은 소스 생성이라면 1 패턴으로 좋지만, IL 생성이라면 복수 패턴 구현해야 하기 때문이다.
예를 들어, 멤버의 값을 취득해도, 멤버를 보관 유지하고 있는 것이, 클래스인가 구조체인가? 멤버가 필드인가 프로퍼티인가? 이것에 의해 전부 분기가 들어가기 때문에 지수 함수적으로 하는 것이 늘어나 간다.
그 밖에 할 수 없는 일을 할 수 있지만, 다른 것보다 어렵다. 이것이 Fody이다.
T4 Template vs Source Generator
정적 메타프로그래밍을 이용하고 싶은 경우, 새로운 수법인 Source Generator를 선택하면 좋을까? 라고 하면, 대략 그렇다라고만 할 수 없다.
Source Generator는 나중에 나와서 세련되어 있고, Code Analyzer나 Code Fix Provider등과 제휴하는 것으로 이용자에게 편리한 것을 제공하기 쉽다.
그렇다면 T4 Template을 사용하는 것이 더 좋은 곳은 어디에 있을까?
전자에 대해서는, 앞에 Source Generator의 설명 시에 했다.
여기에서는 후자에 대해 설명한다.
T4 Template와 Source Generator를 비교했을 때, 큰 차이 중의 하나로 생성된 코드가 파일로서 출력되어 관리되는가? 라는 차이가 있다.
소스가 파일로 출력되고 구성 관리해야 하는 것은 기본적으로 큰 비용이 든다. 예를 들어 풀리퀘스트가 전송되었을 때 제대로 생성된 코드인지 판단하는 것은 매우 넌센스이다.
원래 메타프로그래밍을 하는 것은, 자주 있는 패턴의 구현을 자동적으로 해결하는 것으로 많은 번거로움으로부터 해방되고 싶기 때문이다. 그리고 그 귀찮은 것중 하나는 확실히 "코드를 올바르게 관리하는 것"이 포함되어 있다.
T4 Template를 사용하면 이 번거로움에서 완전히 벗어날 수 없다. 이 점을 보면 Source Generator가 유의하게 보인다.
그러나 잘 생각하면 "굳이 생성 코드를 명시적으로 관리하고 싶다" 라는 케이스는 분명히 존재한다.
메타 모델이 소스 코드 밖에 있는 경우이다.
가장 이해하기 쉬운 것은 데이터베이스 메타 모델에서 매핑 클래스를 자동으로 생성하는 것과 같다.
데이터베이스에서 매핑 클래스를 자동 생성하고 라이브러리화하여 이용하고 싶다고 하자.
그 라이브러리가 생성되었을 때의 데이터베이스 버전은 언제일까?
Source Generator를 이용한 경우, 이것이 알기 어려운 상태가 된다.
T4 Template이면 생성 코드가 구성 관리되기 때문에, 이것은 코드에 의해 자명한 상태를 유지한다.
메타모델을 분석하는 대상이 소스와 같은 프로젝트 내에 포함되는 리소스인가, 아니면 밖에 있는가? 생각해 보면 T4 Template vs Source Generator를 선택하는 하나의 지침이 될 것이다.
요약
지금은 IComparable 보일러 코드를 생성하는 Source Generator 라이브러리를 만들고 있다.
.NET에는 다양한 메타 프로그래밍 기술이 있다.
모두 각각 강점이 있고 적재 적소에 사용해야 한다. 만능은 없다.