Главная страница   /   3.4. Основы T4 (Метапрограммирование в .NET

Метапрограммирование в .NET

Метапрограммирование в .NET

Кевин Хазард

3.4. Основы T4

Теперь, когда вы увидели простой пример T4 в действии, давайте копнем немного глубже в некоторые его основы. Для генерации текста синтаксис T4 предлагает три типа элементов: директивы, текстовые блоки и управляющие блоки. Первыми типами элементов, которые мы рассмотрим, являются директивы и текстовые блоки.

Директивы и текстовые блоки

Директивы управляют движком шаблонизациии в процессе генерации, позволяя вам указать параметры, расширение выходного файла, кодировку созданного файла, связанные сборки и элементы импорта, а также язык, который вы хотите использовать в ваших блоках управления. Директивы окружены специальными разделителями <#@ и #>, как вы видели в примере из предыдущего раздела. Полезно думать о T4 как о своего рода компиляторе. Если бы Т4 был компилятором, большинство встроенных директив были бы параметрами компиляции в командной строке, которые контролируют процесс компиляции и «выпуска» выходных данных. Ниже приводится пара директив, которые говорят T4 загрузить сборку System.Xml.dll и создать выходной файл с расширением .sql:

<#@ assembly name="System.Xml" #>
<#@ output extension=".sql" #>

Теперь подумайте о том, как компилятор C#, который вы можете вызвать из командной строки, обрабатывает ссылки на сборки и именования выходных файлов. Для компилятора Microsoft CSC.EXE, вы можете использовать параметры компиляции командной строки /reference и /out для управления этими опциями. Для T4 директивы <#@assembly#> и <@#output#> позволяют вам сделать то же самое в тексте файла шаблона.

Вторым типом элемента Т4, который нужно понимать, является текстовый блок. Текстовые блоки являются строками сырого текста, который будет вставлен в преобразованные выходные данные. В отличие от директив и управляющих блоков, эти блоки сырого текста не имеют никаких специальных разделителей, окружающих их. На самом деле, отсутствие разделителей T4 – это то, что определяет текстовые блоки. Движок T4 вообще не оценивает содержимое текстового блока, так что это может быть что угодно: исходный код, MIME-кодированные двоичные данные, комментарии к коду и так далее. T4 вставляет текстовые блоки в выходные данные, когда бы ни происходила трансформация.

Текстовые блоки похожи на XML CDATA

Если вы знакомы с XML, вы можете думать о текстовых блоках T4 как о неразобранных символьных данных в разделе XML CDATA. Текстовые блоки T4 позволяют вставлять сырой текст непосредственно в выходные данные шаблона, включая пробелы и непечатаемые символы.

Управляющие блоки

Управляющие блоки бывают различных видов. Для начала, давайте рассмотрим стандартный блок управления.

Стандартный управляющий блок

Вот небольшой шаблон T4, который записывает строку "Hello, World!" в выходной файл:

<#@ template language="C#" #>
<#@ output extension=".txt" #>
Hello, <# WriteLine("world!"); #>

Это простой шаблон демонстрирует все три типа блоков, с которыми вам нужно ознакомиться, чтобы, в конечном счете, освоить T4. Разделители <#@ и #> в первых двух строках показывают, что это директивы. Для этого маленького шаблона директивы говорят T4, что любой управляющий код в шаблоне должен быть интерпретирован как C# и что выходной файл должен иметь расширение .txt. Третья строка представляет как текстовый блок, содержащий слово Hello, и стандартный блок управления, который вызывает встроенную вспомогательную функцию T4 WriteLine.

При сохранении ранее показанного файла шаблона в Visual Studio произойдет несколько вещей. Во-первых, будет вызван инструмент для компиляции шаблона в .NET класс и создания его экземпляра. Затем в сгенерированном классе вызывается метод TransformText, чтобы создать выходные данные, которые записываются в файл. Наконец, Visual Studio вставляет или обновляет созданный файл в проект в качестве так называемого подчиненного файла, связанного с шаблоном. Вы не можете увидеть шаги компиляции и вызова при использовании стандартного Visual Studio T4 хоста, но вы определенно можете увидеть вновь созданный выходной файл.

T4 WriteLine != Console.WriteLine

В первый раз, когда вы видите код в T4, который вызывает функцию WriteLine, как было показано ранее, это может натолкнуть вас на мысль, что это каким-то образом связано с методом WriteLine класса System.Console. Хотя они и названы одинаково, метод Т4 WriteLine имеет совершенно другую реализацию. Если вы вызываете Console.WriteLine по ошибке в шаблоне T4, вы не получите сообщение об ошибке. Но вы также не получите ожидаемый результат, поскольку сгенерированный текст пойдет в невидимое окно консоли вместо выходного файла. Это может вас помучить, пока вы не выясните, где же ваша ошибка.

Если предположить, что небольшой файл шаблона, показанный ранее, был назван HelloWorld.tt, результирующий выходной файл будет называться HelloWorld.txt из-за директивы output extension, указанной в шаблоне. Эти два файла появятся в Solution Explorer Visual Studio, как показано на рисунке 3-1.

Рисунок 3-1: Шаблон HelloWorld и его выходной файл, показанные в окне Solution Explorer Visual Studio

Мы обсудим то, как происходит процесс генерации, немного позже. На данный момент просто поймите, что добавление текстового файла шаблона в проект Visual Studio создает единственный подчиненный файл всякий раз, когда шаблон изменен и сохранен.

Многооператорные стандартные блоки управления

Вызов встроенных вспомогательных функций T4, как WriteLine, в стандартном блоке управления очень прост. Стандартные управляющие блоки могут иметь более одного оператора в них, и именно поэтому их иногда называют блоками операторов. Для создания "Hello, World!" при помощи нескольких операторов можно написать шаблон в несколько ином виде:

<#@ template language="C#" #>
<#@ output extension=".txt" #>
Hello,
<#
	Write("world");
	WriteLine("!");
#>

Два выражения C# в управляющем блоке достаточно легко понять. Метод Write не дает новую последовательность строк в выходные данные, а метод WriteLine дает. Тем не менее, при сохранении, этот шаблон не создает "Hello, World!" на одной строке, как вы могли бы ожидать. Вместо этого, текст расположен на двух строках:

Hello,
world!

Что пошло не так? Откуда появилась дополнительная новая строка? Тщательное рассмотрение шаблона показывает, что она появилась не из управляющего блока, а из текстового блока, который ему предшествует. В текстовом блоке каждый символ интерпретируется в буквальном смысле, в том числе и невидимые символы, как возврат каретки, перевод строки, табуляции и пробелы. Чтобы этот шаблон создавал нужный текст на одной строке, вам нужно его немного изменить:

<#@ template language="C#" #>
<#@ output extension=".txt" #>
Hello, <#
	Write("world");
	WriteLine("!");
#>

Вы заметили, что новая строка в конце текстового блока сейчас была заменена на открывающий тег <# блока управления? Это маленькое изменение, но результат уже будет другой. Данный шаблон создаст "Hello, World!" в выходном файле на одной строке.

Межстрочный интервал в T4

Когда вы впервые начинаете работать с T4, межстрочный интервал может быть немного сложным, если вы работаете с серьезными, многофайловыми шаблонами. Довольно часто при рассмотрении выходного файла, вы будете спрашивать себя: "Откуда взялись эти дополнительные линии?" Для генерации исходного кода дополнительные линии (или их отсутствие) не имеют никакой разницы, в зависимости от языка, на котором вы пишете. Но если вы тип личности А (Type A), эти незначительные детали могут беспокоить вас. Кроме того, плохой межстрочный интервал может повлиять на восприятие программистом кода при чтении. После небольшой практики вы научитесь чувствовать, как работает межстрочный интервал в T4 и установите для себя некоторые простые правила, чтобы ваш сгенерированный код был хорошо сформирован.

Важно также отметить, что символы управления и пробелы внутри управляющего блока T4 имеют значение, только если исходный язык обрабатывает их таким образом. Все внутри блока управления рассматривается исключительно как исходный код, который будет скомпилирован в классе шаблона. Если вы компилируете шаблон с помощью Visual Basic 9 или более ранней версии, вам нужно использовать подчеркивания, когда вы разделяете строки кода в ваших управляющих блоках. Аналогично, если вы включаете C# строку, начинающуюся с символа @ в управляющий блок T4, компилятор будет интерпретировать каждый символ внутри литеральной строки точно так, как он выражается, в том числе любые невидимые табуляции или новые последовательности строк, выраженные внутри литеральной строки.

Управляющие блоки выражений

Использование вспомогательных методов T4 Write и WriteLine может быть громоздким, когда важны читаемость и понимание. T4 предлагает управляющие блоки выражений, чтобы сделать ваш шаблон более свободным. Чтобы указать выражение, откройте блок управления, используя немного другой открывающий разделитель: <#= вместо <#. Внутри блока выражения вы имеете право ссылаться на любые поля или свойства класса шаблона. Также в блоке выражения можно вызвать метод, если он возвращает значение, которое может быть преобразовано в строку. Следующий шаблон будет выводить строку в поле planNumber:

<#@ template language="C#" #>
<#@ output extension=".txt" #>
<#
	int planNumber = 9;
#>
Plan <#= planNumber #> from Outer Space

Управляющий блок выражения <#=planNumber#> между двумя текстовыми блоками в этом небольшом примере выглядит, конечно, более читабельным, чем вызов вспомогательного метода Write. Переменная planNumber не является строкой, но T4 вызовет метод ToString, чтобы преобразовать ее для вас. Передача целочисленного или любого другого типа, который имеет правильно перегруженный метод ToString, не является проблемой для блока выражения Т4.

Блоки выражений могут содержать логику ветвления

Управляющий блок выражения в Т4 может быть ссылкой на любой объект, который может быть преобразован в строку. Вы могли бы предположить, что выражения C#, как <#=A ? B : C #>, также будут работать. В конце концов, это создаст ссылку на объект B или объект C, в зависимости от значения A. Тройной оператор C#, который используется в управляющем блоке выражения, является простым и компактным способом создания очень понятных шаблонов T4.

Обработка отступов

Прежде чем говорить о последней из основ блоков управления в T4, давайте посмотрим, как обрабатываются отступы. Разработчики программного обеспечения, как правило, придирчивы в том, что непрограммисты считают тривиальными, даже бессмысленными деталями, касающимися нашего кода. Форматирование исходного кода является одной из тех тем, которые касаются так называемой религии программистов. Мы также серьезно относимся к нюансам форматирования, потому что мы знаем, что это влияет на понимание. Тщательное размещение разделителей, продолжений строк, отступов может сделать сложный исходный код довольно простым для восприятия. Кроме того, некоторые языки, такие как Python и F# принципиально зависят от отступов, чтобы отразить структуру программы.

Базовый класс, от которого наследуется стандартный хост T4, включает в себя различные вспомогательные функции. Вы уже использовали функции Write и WriteLine в этой главе для форматирования текста, который генерируется в выходной файл. Есть три другие служебные функции, которые доступны для управления отступами:

  • void PushIndent(string indent);
  • string PopIndent();
  • void ClearIndent();

Так же в классе есть строковое свойство только для чтения CurrentIndent, содержащее текст, который будет ставиться в начале каждой строки, созданной T4. Когда начинается трансформация, CurrentIndent является пустой строкой, так что каждая созданная строка не будет иметь символов перед собой.

Внутренне класс-генератор Т4 управляет отступами строк, передаваемых PushIndent по стекоподобной структуре. Вызов PushIndent добавляет строки в верхнюю часть стека. Затем свойство CurrentIndent объединяет все значения в стеке в одну длинную строку. Вызов PopIndent удаляет строку отступа, которая была добавлена последней, и CurrentIndent больше не включает ее. Следующий листинг демонстрирует основные понятия о том, как работает отступ в Т4.

Листинг 3-10: BasicIndentation.tt
<#@ template language="C#" #>
<#@ output extension=".txt" #>
<#
	PushIndent("L1 ");
#>
Item A
<#
	PushIndent("L2 ");
#>
SubItem A1
SubItem A2
SubItem A3
<#
	PopIndent();
#>
Item B
<#
	PushIndent("L2 ");
	for (int ndx = 1; ndx <= 3; ndx++)
		WriteLine("SubItem B" + ndx);
	ClearIndent();
#>
Done.

Выход шаблона BasicIndentation.tt выглядит так:

L1 Item A
L1 L2 SubItem A1
L1 L2 SubItem A2
L1 L2 SubItem A3
L1 Item B
L1 L2 SubItem B1
L1 L2 SubItem B2
L1 L2 SubItem B3
Done.

Мы можем сделать несколько замечаний о коде шаблона и его выходным данным. Во-первых, текущий отступ применяется к тексту, который получен как из текстовых блоков, так и из блоков управления. То, что мы сказали до этого, что текстовые блоки интерпретируются буквально, было не совсем верно. Перед каждой выпущенной строкой будет находиться строковое значение из CurrentIndent, будь она жестко закодированная, как SubItems в текстовом блоке для ItemA, или динамически генерируемая при помощи цикла for в управляющем блоке, как SubItems для ItemB.

Вторая вещь, которую стоит отметить по данному примеру, заключается в том, что вам не нужно делать отступ строго пробелами. Когда вы создаете исходный код для популярных языков, таких как C# или Visual Basic, пробелы для отступа являются нормой. Но как вы видите в листинге 3-10, любая строка может служить отступом, даже такая, как L1, L2 или что-нибудь другое, что имеет смысл для типа вашего выходного файла.

Последнее замечание о листинге 3-10 касается использования вспомогательной функции ClearIndent. Если вам нужно очистить стек всех строк отступов, которые были добавлены, вы можете продолжать вызывать PopIndent, пока свойство CurrentIndent не возвратит пустую строку. Но такой код будет уродливым и громоздким для записи, если доступна вспомогательная функция ClearIndent.

Управляющие блоки элементов класса

Как было сказано выше, T4 динамически создает новый класс, который содержит весь текст и код, определенный в шаблоне, в исполняемой форме. Он также содержит вспомогательные функции, такие как Write и PushIndent, которые вы сможете вызвать в любом месте внутри управляющего кода шаблона. Если шаблон является .NET классом, почему бы вам не добавлять в него свои собственные методы, которые могут быть вызваны, как методы, предоставляемые базовым классом T4? Управляющие блоки элементов класса позволят вам сделать это. Осознание того, что T4 внутренне использует метапрограммирование, поможет вам понять, как работают управляющие блоки элементов класса.

Во многих случаях, когда вы пишете шаблон T4, вы хотите организовать свой управляющий код таким образом, чтобы его можно было вызвать несколько раз. Даже если ваш код не предназначен для многократных вызовов, вы можете посчитать полезным разделить его на отдельные методы и свойства, чтобы сделать его более удобным для чтения. Следующий листинг показывает метод ExpandedTypeName, реализованный в виде управляющего блока элементов класса T4. Учитывая .NET Type, этот метод форматирует строку, содержащую читаемое имя типа. Это может быть полезным в следующем примере, когда вы будете выводить информацию о дженерик методах.

Листинг 3-11: ExpandedTypeName в качестве управляющего блока элементов класса
<#+
	private string ExpandedTypeName(Type t)
	{
		var result = new StringBuilder();
		if (!t.IsGenericType)
		{
			result.Append(t.Name);
		}
		else
		{
			result.Append(t.Name.Substring(0, t.Name.IndexOf('`')));
			result.Append("<");
			int ndx = 0;
			foreach (var tp in t.GetGenericArguments())
				result.AppendFormat(
					(ndx++ > 0) ? ", {0}" : "{0}", tp.Name);
			result.Append(">");
		}
		return result.ToString();
	}
#>

Заметили ли вы последовательность символов <#+ и #>, окружающих функцию? Это специальные разделители, которые окружают управляющий блок элементов класса в Т4. Подумайте о символе «плюс» в таком ключе, как «добавить это в класс». Функция ExpandedTypeName, определенная здесь, принимает объект Type в качестве параметра и возвращает строку, которая выглядит как C# определение для этого типа. Если переданный тип не является дженериком, возвращается простое свойство Name. Но если тип является дженериком, его аргументы форматируются, чтобы походить на C# объявление, а не CLR обозначение, которое обычно возвращает свойство Name. Следующий листинг показывает метод ExpandedTypeName в действии, который используется для получения интересного результата.

Листинг 3-12: TemplateClassDiscovery.tt
<#@ template language="C#" #>
<#@ output extension=".txt" #>
<#@ import namespace="System.Text" #>
<#= ExpandedTypeName(this.GetType())#> Information:
<#
	PushIndent(" ");
	WriteLine("Properties:");
	PushIndent(" ");
	foreach (var pi in this.GetType().GetProperties())
	{
		Write("{0} {1} {{",
			ExpandedTypeName(pi.PropertyType),
			pi.Name);
		WriteLine("{0}{1} }}",
			pi.CanRead ? " get;" : "",
			pi.CanWrite ? " set;" : "");
	}
	PopIndent();
	WriteLine("Methods:");
	PushIndent(" ");
	foreach (var mi in this.GetType().GetMethods())
	{
		Write("{0} {1}(",
			ExpandedTypeName(mi.ReturnType),
			mi.Name);
		var parms = mi.GetParameters();
		if (parms != null)
		{
			for (int ndx = 0; ndx < parms.Length; ndx++)
			{
				Write((ndx > 0) ? ", {0} {1}" : "{0} {1}",
					ExpandedTypeName(parms[ndx].ParameterType),
					parms[ndx].Name);
			}
		}
		WriteLine(");");
	}
#>
<#+
	private string ExpandedTypeName(Type t)
	{
		var result = new StringBuilder();
		if (!t.IsGenericType)
		{
			result.Append(t.Name);
		}
		else
		{
			result.Append(t.Name.Substring(0, t.Name.IndexOf('`')));
			result.Append("<");
			int ndx = 0;
			foreach (var tp in t.GetGenericArguments())
				result.AppendFormat(
					(ndx++ > 0) ? ", {0}" : "{0}", tp.Name);
			result.Append(">");
		}
		return result.ToString();
	}
#>

Если смотреть сверху вниз, то после набора директив шаблона, в управляющем блоке выражений вызывается функция ExpandedTypeName, принимая this.GetType() в качестве параметра. Затем шаблон перебирает свойства и методы, чтобы отобразить некоторую базовую информацию. Вопрос в том, на что ссылается параметр this (или Me в Visual Basic) внутри шаблона T4? Запуск шаблона должен сделать это более понятным. В следующем листинге показаны выходные данные шаблона.

Листинг 3-13: Выходные данные TemplateClassDiscovery.tt
GeneratedTextTransformation Information:
	Properties:
		CompilerErrorCollection Errors { get; }
		String CurrentIndent { get; }
		IDictionary<String, Object> Session { get; set; }
	Methods:
		String TransformText();
		CompilerErrorCollection get_Errors();
		String get_CurrentIndent();
		IDictionary<String, Object> get_Session();
		Void set_Session(IDictionary<String, Object> value);
		Void Initialize();
		Void Dispose();
		Void Write(String textToAppend);
		Void WriteLine(String textToAppend);
		Void Write(String format, Object[] args);
		Void WriteLine(String format, Object[] args);
		Void Error(String message);
		Void Warning(String message);
		Void PushIndent(String indent);
		String PopIndent();
		Void ClearIndent();
		String ToString();
		Boolean Equals(Object obj);
		Int32 GetHashCode();
		Type GetType();

Вы можете видеть по выходным данным, что ссылка this должна быть типа GeneratedTextTransform. У него есть три свойства и двадцать методов, как тут показано. На данный момент вы также должны распознать некоторые другие имена свойств и методов. Например:

  • TransformText
  • Write и WriteLine
  • CurrentIndent, PushIndent, PopIndent и ClearIndent

Сейчас очевидно, что внутри шаблона ссылка this указывает на скомпилированный класс, созданный Т4. При помощи небольшого количества рефлексии и вспомогательной функции, определенной в управляющем блоке элементов класса, вы больше стали понимать о том, как устроен и работает T4.

Где метод, который вы добавили?

Вы можете быть удивлены, почему функция ExpandedTypeName, которая была добавлена в управляющий блок элементов класса, не оказалась в числе 20 методов, показанных в выходных данных листинга 3-13. Причина этого проста. Функция была объявлена как private, а методы Reflection API по умолчанию рассматривают члены public. Измените в шаблоне модификатор доступа для функции ExpandedTypeName на public, сохраните его, и снова посмотрите выходной файл. На этот раз вы увидите 21 метод, в том числе и ExpandedTypeName.