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

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

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

Кевин Хазард

1.2. Примеры метапрограммирования

Для большинства людей, лучший способ научиться – это обучение на примерах. Давайте рассмотрим несколько примеров метапрограммирования в действии. Начнем с простейшего понятия метапрограммирования: вызова динамического JavaScript во время выполнения. Этот пример даст вам понимание о гибкости, которую метапрограммирование может добавить в веб приложение, хотя пример был придуман просто для наглядности.

Далее мы рассмотрим, как использовать интроспективные интерфейсы, чтобы управлять поведением приложения во время выполнения. Благодаря этому вы узнаете, как сделать простую рефлексию, чтобы просмотреть объекты во время выполнения. Но реальная цель этого примера заключается в том, чтобы помочь вам понять соображения производительности при принятии решения в метапрограммировании о включении интерфейса, чтобы сделать его более дружелюбным и более адаптивным во время выполнения.

Третий пример в этом разделе касается генерации кода, возможно, в классическом определении метапрограммирования. Мы покажем вам два типа генерации кода во время выполнения: создание исходного кода из так называемого графа объекта, собранного вручную, и создание исполняемого IL из лямбда-выражения. Для второго типа мы, в первую очередь, дадим C# компилятору сделать тяжелую работу. Затем мы построим лямбда-выражения вручную перед превращением их в работоспособный код.

Последний пример в этом разделе показывает, как можно использовать динамические особенности C# 4 компилятора, чтобы заняться довольно интересным метапрограммированием с небольшими усилиями. Вы узнаете немного о том, как работают типы CallSite и CSharpRuntimeBinder. Реальная цель этого примера, однако, состоит в том, чтобы обратить ваше внимание на некоторые из лучших практик использования динамических типов в C#.

Приведенные здесь примеры предназначены для того, чтобы вы поняли основные понятия, которые понадобятся для изучения дальнейших глав. Также, исследуя несколько простых подходов метапрограммирования в быстрой последовательности, мы надеемся дать вам более целостный взгляд на эту важную парадигму программирования.

Метапрограммирование через скриптинг

Есть много динамических языков программирования. Некоторые из них также считаются скриптовыми языками. Такие языки, как Python или Ruby, хорошо подойдут для нашего первого примера, потому что они имеют чистый, легкий для понимания синтаксис, и они обладают большими возможностями метапрограммирования. Но вместо того, чтобы начать с одного из этих языков, которые могли бы застопорить наше обучение, если вы их не знаете, давайте начнем следующий листинг с двух самых популярных языков в мире.

Листинг 1-1: Динамическая конверсия чисел (HTML и JavaScript)
<!DOCTYPE html>
<html>
<head>
	<script type="text/javascript">
		function convert() {
			var fromValue = eval(fromVal.value);
			toVal.innerHTML = eval(formula.value).toString();
		}
	</script>
</head>
<body>
	<span>fromValue:</span>&nbsp;
	<input id="fromVal" type="text" /><br />
	<span>formula:</span>&nbsp;
	<input id="formula" type="text" /><br />
	<input type="button" onclick="javascript: convert();"
		value="Convert" /><br />
	<span>toValue:</span>&nbsp;<span id="toVal"></span>
</body>
</html>

Довольно непривлекательная веб страница, созданная этой разметкой, демонстрирует основную концепцию метапрограммирования. После обнаружения файла DynamicConversion.htm в образце исходного кода книги, загрузите его и введите значения в поля fromValue и formula, как показано на рисунке 1-2. Обязательно используйте маркер fromValue где-нибудь в формуле для обозначения числового значения, которое вы вводите в поле fromValue.

Рисунок 1-2: DynamicConversion.htm, конвертация дюймов в миллиметры

На рисунке 1-2 показан расчет, который умножает предоставленное пользователем fromValue на 25.4, что является простой формулой для преобразования дюймов в миллиметры. Если набрать в fromValue, например, 3.25 и нажать кнопку Convert, то можно увидеть, что 3.25 дюйма эквивалентны 82.55 миллиметрам. Есть пару битов JavaScript кода на этой веб странице, которые заставляют все это работать: функция convert() и обработчик события onclick для кнопки Convert, который вызывает функцию convert() при нажатии кнопки. В функции convert() используется HTML DOM для извлечения значения из первого текстового поля на странице fromVal. Строка оценивается JavaScript DOM путем передачи функции eval():

var fromValue = eval(fromVal.value);

Это ловкий трюк, но как это работает? Когда мы ввели строку "3.25" в элемент fromVal, мы не думали о написании JavaScript как такового. Мы пытались выразить числовое значение. Но функция eval() интерпретировала наш ввод как JavaScript, потому что это все, что она может делать. Функция eval() дает вам прямой доступ к компилятору JavaScript во время выполнения, поэтому строка "3.25", скомпилированная как JavaScript код, обрабатывается как литеральное значение для числа с плавающей точкой, которое мы знаем как 3.25. Это имеет смысл. Разобранное литеральное число затем присваивается локальной переменной fromValue, определенной в скрипте. Следующая строка кода в функции convert() использует eval() еще раз:

toVal.innerHTML = eval(formula.value).toString();

Строка "fromValue*25.4" выглядит немного больше похожей на скрипт, поскольку она содержит математическое выражение. Результатом выполнения этого скрипта является число, которое преобразуется в строку и записывается обратно на веб страницу для пользователя. Еще раз, в этой одной строке кода вы можете увидеть, как HTML DOM и JavaScript DOM работают вместе, чтобы выполнить то, что требуется.

Немного метапрограммирования, скрытого в этом примере, заключается в способе, которым предопределенная JavaScript переменная fromValue используется в формуле, предоставленной пользователем. fromValue в пользовательской формуле как-то связывается вторым выражением eval() со значением предопределенной переменной в локальной области исполнения DOM. Такое позднее связывание является довольно распространенным в метапрограммировании. С помощью JavaScript написание скрипта, который может относиться к объектам, определенным в большем контексте исполнения, иначе называемым областью скрипта, делается проще. При использовании библиотек JQuery или RxJS в первый раз, кажется совершенно волшебным, как они могут сделать так много за столь малое число строк кода. Волшебство таится в фундаменте метапрограммирования, на котором был реализован JavaScript, что мы и рассмотрим в конце этой главы. Если бы JavaScript таким гениально простым способом не раскрывал бы свой компилятор, не существовало бы ни JQuery, ни RxJS.

Определение локальной переменной fromValue является соглашением по дизайну этой конкретной веб страницы. Вместо того чтобы использовать переменную с определенным именем, вы можете вводить ваши собственные переменные в локальную область и использовать их, как показано на рисунке 1-3.

Рисунок 1-3: DynamicConversion.htm, внедрение переменных в JavaScript

Как вы можете видеть на рисунке 1-3, значение предопределенной переменной fromValue больше не используется в формуле, отправленной пользователем. Этот пример основывается на том факте, что когда первое выражение eval() запускается в функции convert(), любой JavaScript код может быть передан компилятору. Вместо этого новая переменная otherValue вводится в область, на которую ссылается формула. Этот побочный эффект работает должным образом, потому что расчет дюймов в миллиметры дает корректный результат.

Если вы можете создать совершенно новые объекты, используя JavaScript DOM, кто знает, что еще вы могли бы вытянуть из пользовательского скрипта во время выполнения? Например, вы можете иметь доступ к некоторым из встроенных библиотек JavaScript. Давайте попытаемся это сделать. Пример, показанный на рисунке 1-4, использует встроенный JavaScript класс Math для расчета значения тангенса для угла 45 градусов. В случае если вы не помните школьную тригонометрию, тангенс для 45 градусов равен 1.

Рисунок 1-4: DynamicConversion.htm, динамическое использование JavaScript класса Math

Функции tan() нужны радианы, а не градусы. Формула сначала преобразует градусы, предоставленные пользователем, в радианы, используя константу для «пи» (Math.PI) JavaScript класса Math. В JavaScript получение константы для «пи» - очень легкий процесс. Затем снова используется класс Math для вычисления значения тангенса с помощью тригонометрической функции tan(). Результат показывает, что есть небольшая ошибка округления, но он достаточно близко и четко иллюстрирует идею использования JavaScript библиотек из динамического скрипта.

Как видите, название, выбранное для функции convert(), носит довольно четкий характер, ведь вы начинаете видеть, что этот числовой преобразователь может сделать почти все, что хочет пользователь. Например, передайте fromValue строку в одинарных кавычках и вызовите одну или несколько JavaScript строк в формуле, чтобы управлять ей. Вы увидите, что вводимые пользователем данные не должны быть числом вообще. И так будет продолжаться с метапрограммированием в целом. Вы будете часто видеть, что интерфейсы, поддерживающие метапрограммирование, с которыми вы сталкиваетесь, кажутся простыми извне. Под поверхностью, однако, есть много интересной и полезной функциональности, которая часто ждет своего открытия.

Изучив такие важные концепции метапрограммирования, как позднее связывание и компиляция во время выполнения, давайте обратим внимание на еще один популярный способ, который используется во всей .NET Framework Class Library (FCL), чтобы код было легче писать и понимать.

Метапрограммирование через рефлексию

Поверхностная простота, которую часто демонстрируют многие интерфейсы с поддержкой метапрограммирования, часто оказывается преднамеренной. Как вы увидите в этой книге, метапрограммирование обычно используется для того, чтобы скрыть сложности путем предоставления природных интерфейсов для сложных процессов. Давайте взглянем на одно из самых простых применений этой идеи. Представьте себе, что существует элемент управления ListBox с именем listProducts. Ваша цель состоит в том, чтобы загрузить элемент управления со списком (как вы догадались) объектов Product из контекста данных. Каждый Product содержит строковое свойство ProductName и целочисленное свойство ProductID. Вы хотите, чтобы ProductName было видимым для пользователя, а когда пользователь щелкнет по элементу в ListBox, вы хотите, чтобы связанное ProductID было выбранным значением. С .NET 1.0 код для этого вот такой:

listProducts.DisplayMember = "ProductName";
listProducts.ValueMember = "ProductID";
listProducts.DataSource = DataContext.Products;

Этот код может быть прочитан так: "Связываем эти объекты Product с этим ListBox, показывая каждое ProductName пользователю и устанавливая ProductID для каждого элемента в качестве поддерживающего значения, которое можно выбрать". Обратите внимание, как объяснение кода позволяет легко понять, что происходит. На самом деле, код C# и его языковое описание очень похожи.

Вы, возможно, писали код, который делает привязку данных, как мы уже несколько раз показали, но вы задумывались о том, что происходит за кулисами? Как можно использовать строки в строго типизированных языках, как C#, чтобы найти и связать значения свойств по имени во время выполнения? В конце концов, строки, присвоенные свойствам DisplayMember и ValueMember, могли бы быть переменными вместо строковых литералов. Работа с ними с помощью кода связывания данных Microsoft должна быть сделана полностью во время выполнения.

Декларативное программирование

В 1957 году появился язык программирования Фортран (FORTRAN): прадед всех так называемых императивных языков программирования. В английском языке слово императивный (imperative) обозначает команду (command) или долг (duty). Фортран и его потомки называются императивными языками, потому что они дают компьютеру команды, которые нужно выполнять в определенном порядке. Императивные языки хороши для инструктажа компьютера, как делать работу с использованием специфических последовательностей команд. А пример связывания данных намекает на силу стиля программирования, который называется декларативным: он направлен на то, чтобы переместить вас от требования, как компьютер должен работать, к тому, что вы хотите, чтобы было сделано. Вы можете выразить то, что вы хотите, а код связывания данных Microsoft выяснит, как сделать это за вас.

Ответ основан на чем-то, известном как API рефлексии, который может проиллюстрировать внутреннюю работу класса во время выполнения, отсюда и название. Код связывания данных ListBox использует рефлексию для работы с метаданными, оставленными компилятором, как показано в следующем листинге.

Листинг 1-2: Логика рефлексии DataSource (C#)
public System.Collections.IEnumerable DataSource
{
	set
	{
		foreach (object current in value)
		{
			System.Reflection.PropertyInfo displayMetadata =
				current.GetType().GetProperty(DisplayMember);
			string displayString = displayMetadata.GetValue(current, null).ToString();
			// ...
			System.Reflection.PropertyInfo valueMetadata =
				current.GetType().GetProperty(ValueMember);
			object valueObject = valueMetadata.GetValue(current, null);
			// ...
		}
	}
}

Имейте в виду, что реальный код связывания данных немного более оптимизирован, чем этот. Поскольку итерация проходит по каждому элементу в коллекции DataSource, его тип мы получаем при помощи метода GetType(), который наследуется от System.Object.

Примечание

Если у вас есть какие-либо сомнения в том, насколько фундаментальной является рефлексия в .NET экосистеме, на секунду задумайтесь о значении того, что метод GetType() включен в System.Object. Базовый класс для всех. NET типов является довольно малонаселенным, а тут еще и метод GetType(), который имеет огромное значение для открытия метаданных и метапрограммирования. И он считается достаточно важным, поэтому и доступен для каждого .NET объекта.

Объект System.Type, возвращаемый GetType(), имеет метод GetProperty(), который возвращает объект PropertyInfo. В свою очередь PropertyInfo имеет метод, определенный внутри него, называемыйGetValue(), который используется для получения значения времени выполнения свойства объекта, который реализует метаданные, описанные PropertyInfo.

В пространстве имен System.Reflection вы можете быть заинтересованы в некоторых из этих Info классов для выражения различных типов метаданных, таких как FieldInfo, MethodInfo, ConstructorInfo, PropertyInfo и так далее. Как показано в листинге 1-2, эти классы однозначны по своей природе. Если у вас есть класс Info, вы должны предоставить экземпляр типа, в котором вы заинтересованы, чтобы сделать что-нибудь полезное. В листинге 1-2, текущий Product в цикле передается методу GetValue(), чтобы загрузить значения экземпляра для каждого целевого свойства. Теперь, когда вы знаете, что классы Info в рефлексии однозначны, вы можете подумать о повторном их использовании для оптимизации кода связывания данных. И вот так думает настоящий метапрограммист! Следующий листинг показывает оптимизированную версию кода.

Листинг 1-3: Оптимизированная логика связывания DataSource (C#)
public IEnumerable DataSource 
{
	set 
	{
		IEnumerator iterator = value.GetEnumerator();

		object currentItem;
		do {
			if (!iterator.MoveNext())
				return;
			currentItem = iterator.Current;
		} while (currentItem == null);

		PropertyInfo displayMetadata = currentItem.GetType().GetProperty(DisplayMember);
		PropertyInfo valueMetadata = currentItem.GetType().GetProperty(ValueMember);

		do {
			currentItem = iterator.Current;
			string displayString = displayMetadata.GetValue(currentItem, null).ToString();
			// ...
			object valueObject = valueMetadata.GetValue(currentItem, null);
			// ...
		} while (iterator.MoveNext());
	}
}

Первая порция оптимизированного кода связывания данных DataSource, показанная в листинге 1-3, проходит итерации до тех пор, пока не найдет текущий элемент не-null. Это необходимо, поскольку вы не можете предположить, что в коллекции, представленной как DataSource, все элементы являются не-null. Первые элементы могут быть пустыми. Как только находится элемент, некоторые из его типовых метаданных кэшируются для последующего использования. Тогда итерации по элементам используют кэшированные объекты PropertyInfo для извлечения значений от каждого элемента. Как вы можете себе представить, это более эффективный подход, потому что вы не должны выполнять дорогостоящее извлечение метаданных для каждого объекта в коллекции. Использование кэширования и другой оптимизации для улучшения производительности во время выполнения является общей практикой метапрограммирования.

Проблема "магических строк"

Одним из недостатков любого подхода метапрограммирования, который использует литеральные строки, чтобы управлять поведением приложения во время выполнения, является то, что компиляторы не могут выполнить верификацию во время компиляции. Что произойдет, если вы неправильно определили значение DisplayMember как "ProductNane"? Вы быстро обнаружите эту ошибку во время тестирования. Но что если вы разрешили пользователю указать эту строку через настройку приложения, или хуже того, с помощью параметра запроса? Злоумышленники могут начать проверку наличия так называемых магических строк, которые могут быть использованы для того, чтобы раскрыть ваш код для новых моделей поведения. Целые классы могут быть подвержены атакам SQL инъекций, от которых все еще страдают плохо разработанные веб сайты, несмотря на то, что решение данной проблемы занимает всего несколько минут.

Для краткости реализация связывания данных DataSource здесь не показана. Она включает в себя много интересных видов оптимизации, о которых вы можете узнать. Когда вы будете готовы, используйте навыки, которые вы почерпнете в главе 2, для анализа реального кода связывания данных. И вы узнаете много нового из этого.

Далее мы обратим наше внимание на идею генерации кода, и это то, как большинство разработчиков определяют метапрограммирование.

Метапрограммирование через генерацию кода

До сих пор мы рассмотрели скрипты и рефлексию в качестве инструментов метапрограммирования. Теперь давайте сосредоточимся на создании нового кода во время выполнения. Чтобы было легче вникнуть в тему, мы сориентируемся на двух более простых подходах генерации кода с использованием Microsoft .NET Framework:

  • Создание кода при помощи CodeDOM
  • Генерирование IL при помощи деревьев выражений

Итак, эти подходы совершенно разные, но результаты различаются только тем, что при одном подходе создается текст исходного кода, а при другом – представляются новые функции, которые являются непосредственно исполняемыми файлами.

Создание исходного кода во время выполнения при помощи CodeDOM

Документно-ориентированные модели программирования являются общими в разработке программного обеспечения, так как документ является мощным и простым инструментом для организации информации. Возможно, вы использовали при разработке HTML DOM и JavaScript DOM. А Microsoft включил CodeDOM в .NET Framework. Как следует из названия, CodeDOM позволяет применять документно-ориентированный подход к генерации кода.

CodeDOM появился на начальном этапе становления .NET, и он отражает некоторые из наиболее примитивных соображений о стандартизированной системе генерации кода для платформы Microsoft. Термин «примитивный» не является уничижительным в данном случае, потому что CodeDOM, несмотря на то что Microsoft не сосредотачивал на нем свое внимание в последние годы, по-прежнему является элегантной системой генерации кода, которой многие метапрограммисты пользуются до сих пор. CodeDOM использует так называемый подход, основанный на графах кода, для создания кода на лету.

Для всех кусков кода CodeDOM, продемонстрированных в этом разделе, необходим импорт следующих пространств имен:

using System;
using System.IO;
using System.Text;
using System.CodeDom;
using System.Diagnostics;
using System.CodeDom.Compiler;

Чтобы понять, как функционирует CodeDOM в качестве генератора исходного кода, давайте начнем с рассмотрения того, какие языки программирования .NET поддерживают CodeDOM. Класс CodeDomProvider является одним из центральных классов в пространстве имен System.CodeDom.Compiler и включает в себя удобный статический метод GetAllCompilerInfo(), который возвращает массив объектов CompilerInfo. Каждый объект CompilerInfo имеет метод GetLanguages(), который можно использовать для получения списка маркеров, которые могут быть использованы для создания экземпляра провайдера языка, например:

foreach (System.CodeDom.Compiler.CompilerInfo ci in
	System.CodeDom.Compiler.CodeDomProvider.GetAllCompilerInfo())
{
		foreach (string language in ci.GetLanguages())
			System.Console.Write("{0} ", language);

		System.Console.WriteLine();
}

Выполнение этого фрагмента в консольном приложении или в LINQPad генерирует список синонимов для каждого из установленных провайдеров языков в системе. Рисунок 1-5 показывает LINQPad, который работает как своего рода блокнот C# для выполнения этого фрагмента кода.

Рисунок 1-5: Перечисление синонимов для провайдеров языков в CodeDOM при помощи LINQPad

Как вы можете видеть по выходным данным в LINQPad, пять провайдеров языков установлены в нашей системе: C#, Visual Basic, JavaScript, Visual J# и Managed C++. Каждый провайдер допускает использование трех или четырех синонимов. Мы вернемся к экземплярам провайдеров в конце этого примера.

Обратите внимание, что F# не входит в число поддерживаемых языков. Microsoft не прилагал много усилий для поддержки CodeDOM в последние несколько лет. Лишь были небольшие улучшения и исправления в последних версиях .NET Framework, но не ожидайте увидеть всех новых провайдеров языков. Microsoft до сих пор использует CodeDOM в своих основных подсистемах. Движок T4 и генератор страниц ASP.NET по-прежнему зависят от CodeDOM для генерации кода. В дальнейшем, однако, Microsoft почти наверняка продолжит сосредотачивать свои исследования и ресурсы на таких инструментах генерации кода, как Roslyn API.

LINQPad: инструмент, который нужен любому .NET разработчику

Мы крайне редко говорим категорично об инструментах разработки. Как программисты- полиглоты мы восхищаемся большинством инструментов. Хотя время от времени попадается такой ценный инструмент, который мы просто должны рекомендовать каждому разработчику. LINQPad, написанный Джо Албахари, является таким инструментом. Он может быть использован в качестве блокнота для вашего .NET кода. Как следует из названия, он также хорошо помогает при написании и отладке LINQ запросов. На момент написания книги LINQPad можно было бесплатно загрузить с http://LINQPad.net. Если у вас его еще нет, мы рекомендуем вам скачать его и сразу начать использовать.

Теперь давайте взглянем на динамическую генерацию класса. CodeDOM использует концепцию графа кода, чтобы программно собирать .NET объекты. В C# исходный файл может начинаться с объявления пространства имен, а граф CodeDOM, как правило, начинается с создания объекта System.CodeDom.CodeNamespace. CodeNamespace является корнем графа. Возвращаясь к аналогии с исходным кодом, в фигурных скобках, следующих после объявления namespace в C#, хранятся типы, которые будут определены в нем. CodeNamespace в CodeDOM ведет себя таким же образом. Это контейнер, в котором могут быть определены различные типы и код. Прежде чем перейти к примеру кода, давайте воспользуемся моментом, чтобы посмотреть, как работает код. Вот шаги:

  1. Создать CodeNamespace. Это класс CodeDOM, который представляет пространство имен CLR (Common Language Runtime). Мы назовем наше пространство имен MetaWorld, чтобы сделать его запоминающимся.
  2. Создать CodeNamespaceImport, чтобы импортировать пространство имен System в созданный исходный код. Это похоже на объявления using в C# или Import в Visual Basic.
  3. Создать CodeTypeDeclaration с именем Program для класса, который будет сгенерирован. Это похоже на использование ключевого слова class в коде, когда объявляется новый тип.
  4. Создать CodeMemberMethod с именем Main, который будет функцией точки входа в класс Program. Объект метода будет вставлен в класс Program. Класс Program определяется в пространстве имен, а функция Main определяется в классе Program.
  5. Создать CodeMethodInvokeExpression, чтобы вызвать "Console.WriteLine" с параметром CodePrimitiveExpression "Hello, World!". Это самая трудная часть для понимания из-за того, как структурирован код.

Вы, вероятно, видите, как это происходит. Мы будем динамически генерировать "Hello, World!" при помощи кода, показанного в следующем листинге.

Листинг 1-4: Сборка программы “Hello, world!” при помощи CodeDOM (C#)
partial class HelloWorldCodeDOM
{
	static CodeNamespace BuildProgram()
	{
		var ns = new CodeNamespace("MetaWorld");

		var systemImport = new CodeNamespaceImport("System");
		ns.Imports.Add(systemImport);

		var programClass = new CodeTypeDeclaration("Program");
		ns.Types.Add(programClass);

		var methodMain = new CodeMemberMethod
		{
			Attributes = MemberAttributes.Static,
			Name = "Main"
		};

		methodMain.Statements.Add(
			new CodeMethodInvokeExpression(
				new CodeSnippetExpression("Console"),
				"WriteLine",
				new CodePrimitiveExpression("Hello, world!")
			)
		);

		programClass.Members.Add(methodMain);

		return ns;
	}
}

Примечание

В главе 4 мы покажем, как динамически генерировать код с помощью CodeDOM. В небольшом примере, показанном здесь, мы для простоты использовали CodeSnippetExpression. Использование этого объекта CodeDOM может застопорить вас на создании кода для одного конкретного языка, что часто мешает программистам даже начать использовать CodeDOM.

Метод BuildProgram(), показанный в листинге 1-4, инкапсулирует скрипт, отмеченный ранее, возвращая объект CodeNamespace. Вы еще не отобразили исходный код. Это будет дальше. Объект CodeNamespace может быть использован CodeDomProvider для создания исходного кода. Теперь вы должны использовать один из пяти основных провайдеров языков, установленных на нашем компьютере, чтобы сделать работу. В примере из листинга 1-5 выполняются для этого следующие шаги:

  1. Создать объект CodeGeneratorOptions, чтобы сказать выбранному компилятору, как себя вести. При помощи этого класса вы можете контролировать отступы, межстрочный интервал и так далее.
  2. Создать StringWriter, в который провайдер языка будет направлять сгенерированный исходный код. Присоединенный StringBuilder содержит созданный исходный код.
  3. Создать провайдер языка С# и вызвать метод GenerateCodeFromNamespace, передав CodeNamespace, построенный методом BuildProgram(), показанном в листинге 1-4.

После выполнения StringBuilder будет содержать исходный код. Наша программа выводит исходный код на консоль. Но он может так же легко быть записанным на диск.

Листинг 1-5: Создание исходного кода из CodeNamespace (C#)
partial class HelloWorldCodeDOM
{
	static void Main()
	{
		CodeNamespace prgNamespace = BuildProgram();

		var compilerOptions = new CodeGeneratorOptions()
		{
			IndentString = " ",
			BracingStyle = "C",
			BlankLinesBetweenMembers = false
		};

		var codeText = new StringBuilder();

		using (var codeWriter = new StringWriter(codeText))
		{
			CodeDomProvider.CreateProvider("c#")
				.GenerateCodeFromNamespace(prgNamespace, codeWriter, compilerOptions);
		}

		var script = codeText.ToString();

		Console.WriteLine(script);
	}
}

Скомпилируйте и запустите эту маленькую программу генерации кода, чтобы увидеть хорошо отформатированную C# программу, которую она создает в следующем листинге.

Листинг 1-6: Сгенерированный CodeDOM исходный код C# для “Hello, world!”
namespace MetaWorld
{
	using System;
	public class Program
	{
		static void Main()
		{
			Console.WriteLine("Hello, world!");
		}
	}
}

Создание исходного кода C# оказалось легким делом, не так ли? Но что будет, если вы захотите сгенерировать исходный код Managed C++ для этой же программы? Вы можете быть удивлены тем, насколько простым является это изменение. Измените строку "c#" в вызове CodeDomProver.CreateProvider() из листинга 1-5 на "c++", а метапрограмма сгенерирует кода C++. Следующий листинг показывает версию C++ динамически созданного исходного кода после внесения этого небольшого изменения.

Листинг 1-7: Сгенерированный CodeDOM исходный код C++ для “Hello, world!”
namespace MetaWorld {
	using namespace System;
	using namespace System;
		ref class Program;
		public ref class Program
		{
			static System::Void Main();
		};
}
namespace MetaWorld {
	inline System::Void Program::Main()
	{
		Console->WriteLine(L"Hello, world!");
	}
}

Выходные данные слегка модифицированной программы являются красиво отформатированным исходным кодом на языке Managed C++, который вы могли бы сохранить на диск для дальнейшей компиляции, например. Вы видели по LINQPad на рисунке 1-5, что можно использовать также синонимы "mc" и "cpp" для создания экземпляра провайдера языка C++. Также доступны и остальные провайдеры для Visual Basic, JavaScript и Visual J#, которые можно использовать для создания хорошо отформатированного кода на этих языках. Попробуйте поиграть с ними, и вы увидите, что переключение на другой язык при создании исходного кода из графа кода CodeDOM не требует фактически никаких усилий.

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

  • Создание классов сущностей из метаданных базы данных для инструмента ORM во время процесса сборки.
  • Автоматизация генерации SOAP клиента для встраивания функций в прокси-классы, которые не доступны через инструменты командной строки Microsoft.
  • Автоматизация генерации пограничных тестов для кода на основе простого параметра метода и анализа типа возвращаемого значения.

Этот список можно продолжать и продолжать. Какой бы ни была причина вашего желания создавать исходный код, CodeDOM делает это довольно легко. CodeDOM – это не единственный способ для создания исходного кода в .NET Framework, но как только вы привыкнете к классам в пространстве имен System.CodeDom, вы увидите, что это не плохой выбор. Предыдущие примеры были намеренно упрощены. Когда вы будете готовы углубиться в CodeDOM, смотрите главу 4, которая посвящена CodeDOM и в которой показано много передовых методик метапрограммирования с богатыми, многоразовыми примерами.

Теперь, когда мы рассмотрели выражение код в качестве данных, давайте обратим наше внимание на другой способ сделать это в .NET Framework.

Создание IL во время выполнения при помощи деревьев выражений

Один из наиболее распространенных методик метапрограммирования выражается в представлении кода в виде данных. Это может показаться немного странным на первый взгляд. Пример с CodeDOM, описанный в последнем разделе, представил код в виде набора структур данных, чтобы показать исходный код. Пожалуй, более интересная практика метапрограммирования предполагает компиляцию данных, представляющих тело кода в сборке, которая может быть сохранена на диске или выполнена немедленно по запуску приложения. Это вырезает необходимости компилировать промежуточные файлы исходного кода. А еще лучше, если бы граф кода был так или иначе независим от архитектуры машины, он мог бы быть перенесен на удаленный компьютер, чтобы быть там скомпилированным и выполненным. Удаленный компьютер не обязательно должен использовать ту же операционную систему или даже ту же архитектуру процессора, если он имеет средства для компиляции последовательной структуры данных. Типы в сценариях компиляции в памяти весьма чаще встречаются при метапрограммировании, чем те, которые создают исходный код на промежуточном этапе.

Чтобы продемонстрировать эту идею компиляции в памяти, граф кода должен каким-то образом быть собранным в IL во время выполнения. Как выясняется, классы CodeDOM, которые вы рассмотрели, могут скомпилировать графы кода и блоки сырого исходного кода, написанного на одном из поддерживаемых языков, в .NET сборку. Эти динамически генерируемые сборки могут быть записаны на диск для будущего использования или раскрыты как типы в оперативной памяти для немедленного применения в текущем приложении. Есть также классы в пространстве имен Reflection.Emit, которые хорошо подходят для генерации IL. Но оба подхода, с CodeDOM и Reflection.Emit, немного сложны для вводной главы, когда разработчики слышат о метапрограммировании в первый раз. Оба подхода, с CodeDOM и Reflection.Emit, являются важными, и именно поэтому мы посвящаем им главы 4 и 5, соответственно. Чтобы освоиться с динамической генерацией IL в .NET прямо сейчас, мы будем использовать деревья выражений, которые являются лучшим средством для изучения основ.

Чтобы понять деревья выражений, нам нужно вспомнить немного истории о делегатах в .NET Framework и языках. Делегаты были введены в первой версии платформы. Они были довольно медленными в первое время, поэтому многие разработчики избегали использовать их в интенсивной работе. Ранние делегаты, выраженные в C# и Visual Basic, также должны были быть названными во время компиляции, что тоже не приводило к их популярности. Когда появился Framework 2.0, в язык C# были добавлены анонимные методы. И в это время реализация делегатов во время выполнения также намного улучшилась в плане производительности. Это были шаги в правильном направлении. Функции высшего порядка теперь могли быть объявлены внутри без присвоения им имен. Они также показывали хорошие результаты во время выполнения. Анонимные методы сделали синтаксис делегатов C# гораздо более последовательным, но языку по-прежнему не хватало общей выразительной силы действительно функциональных языков программирования.

От указателей на функцию в C++ к деревьям выражений в .NET

Язык С++ использует так называемые указатели на функции, чтобы передавать функции как параметры другим функциям. Используя эту технику, «вызыватель» функции может обеспечить разнообразие реализаций во время выполнения, передавая ту функцию, которая наилучшим образом соответствует текущим потребностям приложения. Это звучит знакомо? Действительно, эти так называемые функции высшего порядка в C++ создают такой вид структуры приложения, который может быть использован для метапрограммирования.

Проблема с этим подходом заключается в том, что компилятор не может проверить, что параметры или возвращаемый тип функции точно совпадают с ожиданиями вызывающего компонента. .NET Framework 1.0 заставил делегатов решать эту проблему. Делегаты можно передавать как ссылки на функции, но они в полной мере соблюдают типовую «технику безопасности». С новыми релизами .NET Framework концепция делегатов значительно эволюционировала. Сегодня у нас есть деревья выражений .NET, которые мастерски сочетают понятия функций высшего порядка с кодом в качестве данных и компиляцией во время выполнения, что превращается в богатый инструментарий для повседневного метапрограммирования.

Что делает язык программирования функциональным?

Согласно ученому Джону Хьюзу и его работе "Почему функциональное программирование" (“Why Functional Programming Matters”) языки программирования можно считать функциональными, если они имеют первоклассную поддержку функций высшего порядка и отложенных вычислений. Функциями высшего порядка являются те, которые принимают другие функции в качестве параметров или возвращают новые функции. В языке C# такая возможность была с самого начала, это делегаты в Common Language Runtime (CLR). Отложенные вычисления обозначают ожидание нужного расчета для их выполнения. Библиотека классов .NET включает тип Lazy<T> для отсрочки исполнения, но это не языковая конструкция. Языки C# и Visual Basic поддерживают синтаксис yield return в своих блоках итераций, которые демонстрирует полезный вид отложенных вычислений для спискового включения (list comprehension). Но это не те отложенные вычисления, о которых говорил доктор Хьюз. Если вы хотите оценить истинные возможности отложенных вычислений, вы должны обратить внимание на F#, который является единственным .NET языком, поддерживающим их.

В 2006 году Microsoft добавил деревья выражений в BCL (Base Class Library) и поддержку лямбда-выражений в языки C# и Visual Basic. Эти функции были добавлены для поддержки LINQ. Благодаря LINQ .NET языки могут серьезно конкурировать с более функциональными языками для построения так называемых списковых включений (list comprehensions). Этот термин восходит в истории информатики. В настоящее время лучший способ осмысления спискового включения заключается в том, что оно позволяет, чтобы списки объектов могли быть созданы из других списков. Это звучит так же абсурдно, как оно и есть. Если подумать, разве не большая часть нашей работы в разработке предусматривает создание списков и манипуляции над ними? Действительно, обработка списков является одним из тех основных принципов, которые могут возвысить или «сломать» язык программирования.

С помощью новых LINQ-ориентированных возможностей, добавленных в C#, функция, которая обычно принимает два параметра и возвращает результат, может быть выражена следующим образом:

public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);

Функции, которые сравнивают одно целое число с другим и возвращают булев результат, безусловно, будут соответствовать этому шаблону. Экземпляр функции, которая проверяет, является ли параметр Left больше, чем параметр Right, может быть выражен следующим образом:

public bool GreaterThan(int Left, int Right)
{
	return Left > Right;
}

Это может показаться странным – думать об "экземплярах функций", но эта понятие метапрограммирования прояснится в ближайшие несколько минут. С функцией GreaterThan, определенной выше, все в порядке, но использовать ее в качестве предиката для фильтрации результатов запроса, например, немного громоздко. Тот факт, что это независимо определенная и названная функция, является частью проблемы. Чтобы использовать ее, вам придется обернуть ее в определенный тип делегата или закрытый универсальный тип Func<int,int,bool>. C# теперь предлагает гораздо более краткий способ сделать это с помощью лямбда-выражения:

(Left, Right) => Left > Right

Оператор => читается как "переходит к": это выражение вы можете прочитать как "параметры Left и Right переходят к результату тестирования, является ли Left больше, чем Right". Прежде всего, обратите внимание, что нет никакого требования, чтобы параметры Left и Right были любого конкретного типа. Кто знает, вы могли бы сравнивать числа с плавающей точкой, целые числа, строки. Но для компиляторов, как C#, которые не так хорошо умеют делать глубокий анализ типов, как F#, вы должны быть более конкретными:

Func<int, int, bool> GreaterThan = (Left, Right) => Left > Right;

Теперь компилятор знает, что параметры Left и Right являются целочисленными типами.

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

int Left = 7;
int Right = 11;
System.Console.WriteLine("{0} > {1} = {2}", Left, Right, GreaterThan(Left, Right));

Это выведет на консоль 7 > 11 = False, как вы и ожидали. Способность дать определение функциональным делегатам, используя лямбда-выражения, конечно, делает код более кратким. Поддержка компилятора для лямбда-выражений довольно хороша, но использование лямбда-выражений таким образом – это не тот способ, который показывает их реальную ценность. Более типичным является использование их встроенными в LINQ выражения. На рисунке 1-6 показано, как снова используется LINQPad. На этот раз мы выполняем перекрестное объединение в LINQ, чтобы умножить один диапазон чисел на другой. Функция Dump() является возможностью LINQPad, которая облегчает получение снимка результатов выражений. Если бы вы запустили код из примера в консольном приложении, вам нужно было бы вывести результаты с помощью пользовательского кода.

Рисунок 1-6: Объединение двух диапазонов в LINQ

С двумя диапазонами по 10 значений в каждом результат содержит 100 элементов, как показано в LINQPad вкладке Results. Сорок пять из этих 100 результатов являются избыточными, потому что пересекающиеся диапазоны перекрываются, и умножение является коммутативным. Вы можете устранить дубликаты путем сравнения значений строк и столбцов в предикате, добавив фильтр:

qry.Where(a => a.Row >= a.Column).Dump();

Обратите внимание, как лямбда-выражение, переданное стандартному оператору запроса Where(), выглядит очень похожим на GreaterThan Func<int,int,bool>, показанную ранее. Разница в том, что вместо того, чтобы взять два параметра, два сравниваемых значения достигаются из свойств одного параметра. Если использовать это выражение в стандартном операторе запрос Where(), он отфильтрует результаты. Мы называем этот тип фильтрующей функции предикатом.

Вы можете прочитать выражение предиката a => a.Row >= a.Column как "Возвращение верно для элементов, где число Row больше или равно числу Column". Если вы привыкли к LINQ, тот способ, которым это выражение используется в контексте, может показаться немного запутанным. Что за параметр a? Откуда он взялся? Какого он типа? Одна из подсказок может быть найдена во вкладке Results в LINQPad. Обратите внимание на рисунке 1-6, что результат Dump() имеет тип IEnumerable<>. Запрос должен составить список чего-то. Тем не менее, вы до сих пор не знаете, какой тип имеют эти элементы в списке, потому что синтаксис select new{…} был использован для создания анонимного типа, который не имеет названия с точки зрения программиста. Вы можете сказать по выходным данным, что каждый неназванный элемент имеет свойство Row, свойство Column и свойство Product. За кулисами, элементы в списке имеют именованный тип, но вы не захотите прочитать его. Для анонимных типов компилятор генерирует длинное, странное имя, которое имеет смысл только внутри. Если бы LINQPad показывал имя в Results, это только мешало бы.

Теперь, когда вы понимаете, что запрос создает список анонимно типизированных объектов, параметр с именем a в лямбда-выражение имеет теперь немного больше смысла. В данном случае название a было выбрано потому, что каждый из объектов, переданных функции, будет одним из тех анонимных типов. Вы могли бы использовать любое имя для параметра. Но когда лямбда-функции встроены так, как эта, мы часто используем имена параметров, которые более компактны. Это увеличивает понимания, поскольку использование функции так близко к ее определению, что длинные описательные имена зачастую не нужны. Когда функции объявлены старомодным способом и полностью отделены от своих мест использования, более описательные имена параметров помогают улучшению понимания.

На рисунке 1-7 при чтении чисел сверху вниз и слева направо вы можете увидеть, как выглядят улучшенные результаты отфильтрованного запроса.

Рисунок 1-7: Результаты фильрации двух пересекающихся диапазонов при помощи лямбда-предиката

45 повторяющихся значений, которые могли бы появиться на нижней и левой сторонах, были отфильтрованы предикатом. Запустите отфильтрованный запрос в LINQPad, чтобы увидеть, что он создает результат, показанный на рисунке 1-7. Тогда попробуйте поиграть с другими предикатами, чтобы разными способами манипулировать результатами. В конце концов, лучший способ учиться – это играть.

Важность игры

Для взрослых некоторые виды игр являются простым отвлечением. Игры от первого лица со смыслом убить больше врагов, безусловно, помогают смыть заботы дня, но, вероятно, не помогают установить прототипы, которые нужны вашему мозгу, чтобы поглощать новые идеи. Игра может быть полезна в качестве учебного пособия. Маленьким детям не хватает аналитических навыков, поэтому они используют игру, чтобы накопить опыт и знания о том, как устроен мир. Когда вы взрослеете, ваша игра становится все более структурированной, и, в конце концов, вы даже можете забыть, как играет ребенок. В этой книге мы рекомендуем вам играть с тем, чему мы вас учим. Когда мы показываем вам один из способов сделать что-то, попробуйте несколько вариаций, чтобы знания засели глубоко в вашем уме.

Теперь, когда вы понимаете, как фильтровать запросы с помощью лямбда-выражений в LINQ, вы готовы понять интересную связь метапрограммирования. Просьба C# компилятору превратить лямбда-выражение в функцию – это чисто процесс, происходящий во время компиляции. Это может выглядеть новомодным, но это по-прежнему относится к старой школе, как говорится. Что делать, если вам нужно быть в состоянии передать различные фильтрующие предикаты в зависимости от обстоятельств на данный момент? Более того, что если некоторые из этих алгоритмов фильтрации не могут быть известными во время компиляции? Или, возможно, они приходят от удаленного процесса, который может создать новые фильтры на лету, отправляя их по проводам вашему приложению для компиляции и использования. Метапрограммирование в помощь!

Перемещение от идеи конкретных функции, работающих во время компиляции, к более универсальной абстракции этих функций является довольно простым в .NET благодаря так называемым деревьям выражений и последним компиляторам от Microsoft. Вы уже видели, как C# компилятор может превратить лямбда-выражение в реальную функцию, вы можете вызвать, как и любую другую. Внутри компиляторы работают путем разбора (парсинга) текста исходного кода в абстрактные синтаксические деревья (AST). Эти деревья соблюдают определенные основные правила для выражения кода в виде данных. Например, когда лямбда-выражения компилируются, они укладываются в результирующие AST, как любая другая .NET конструкция.

Что случилось с Compiler-as-a-Service?

На Конференции профессиональных разработчиков Microsoft в 2008 году Андерс Хейлсберг рассказал о грядущих изменениях в будущих версиях языка программирования С#. Одно из них называлось Compiler-as-a-Service. Основной идеей было раскрыть для использования некоторый функционал «черного ящика» C# компилятора для разработчиков за пределами Microsoft. Microsoft с тех пор как-то подзабыл это название, но идеи, известные как Compiler-as-a-Service, живы и здоровы. В главе 10 мы это обсудим.

Что было бы, если вы могли бы сохранить AST, созданный компилятором, так чтобы он мог быть изменен во время выполнения в соответствии с вашими потребностями? Если бы это было возможно, были бы возможны всякие интересные вещи. К сожалению, на момент написания этой книги парсер и генератор AST для C# все еще реализованы таким образом, что средний разработчик не может использовать их для реализации только что описанных динамических сценариев выполнения. Но деревья выражений в .NET Framework являются интересным шагом в этом направлении.

Деревья выражений были введены в .NET 3.0 для поддержки LINQ. Затем, в версии 4.0, они были сильно усовершенствованы для поддержки Dynamic Language Runtime (DLR). В дополнение к усовершенствованным деревьям выражений в версии 4.0 .NET компиляторы получили возможность разбивать C# и Visual Basic код непосредственно на выражения. Вспомните функцию GreaterThan(), ранее определенную как лямбда-выражение. Помните, Func<int,int,bool>, которая создала реальную, вызываемую функцию во время компиляции? Теперь посмотрите на следующую строку кода и найдите отличия:

Expression<Func<int, int, bool>> GreaterThanExpr =
	(Left, Right) => Left > Right;

Синтаксически GreaterThan Func<> была заключена в тип Expression<> и переименована в GreaterThanExpr. Новое имя выделит ее в дальнейшем обсуждении. Но лямбда-выражение выглядит точно таким же. Каков результат изменения? Во-первых, если вы попытаетесь скомпилировать вызов этого нового выражения GreaterThanExpr, оно не будет выполнено:

bool result = GreaterThanExpr(7, 11); // не скомпилируется!

Выражение GreaterThanExpr не может напрямую быть вызвано в качестве функции, как могла GreaterThan. Это потому что после компиляции GreaterThanExpr является данными, а не кодом. Вместо компиляции лямбда-выражение в немедленно работоспособную функцию, компилятор С# строит объект Expression. Чтобы вызвать выражение, необходимо сделать еще один шаг во время выполнения, чтобы преобразовать эти данные в работающую функцию.

Func<int, int, bool> GreaterThan = reaterThanExpr.Compile();
bool result = GreaterThan(7, 11); // компилируется!

Класс Expression предоставляет метод Compile(), который может быть вызван для представления работоспособного кода. Эта динамически генерируемая функция идентична той, которая создается как старомодным, отдельно определенным методом GreaterThan, так и прекомпилированным Func<> делегатом с тем же именем. Вызов метода для компиляции выражений во время выполнения может показаться немного странным на первый взгляд. Но как только вы почувствуете силу динамически собранных выражений, вы начнете чувствовать себя как дома.

Как LINQ использует выражения

LINQ запросы имеют прямую выгоду от того, как могут быть скомпилированы Expression. Но различные LINQ провайдеры обычно не вызывают метод Compile(), как показано тут. Каждый провайдер имеет свой собственный метод для преобразования деревьев выражений в код. Когда компилируется предикат как a => a.Row > a.Count, он может создать IL, который можно вызвать в .NET приложении. Но это же самое выражение может быть использовано для создания условия WHERE в SQL выражении, или запросов XPath, или выражения OData $filter. В LINQ деревья выражений выступают в качестве своего рода нейтральной формы для передачи намерений кода. LINQ провайдеры интерпретируют это намерение во время выполнения, чтобы превратить его в нечто, что может быть выполнено.

Как вы видели в предыдущем примере, C# компилятор может построить для вас Expression во время компиляции. Это, конечно, удобно, но вы также можете собрать лямбда-выражения вручную, что может быть полезным в некоторых приложениях. Код в следующем листинге показывает, как построить и собрать программно класс Expression, который реализует функцию GreaterThan, виденную ранее.

Листинг 1-8: Сборка лямбда Expression вручную
using System;
using System.Linq.Expressions;
class ManuallyAssembledLambda
{
	static Func<int, int, bool> CompileLambda()
	{
		ParameterExpression Left = Expression.Parameter(typeof(int), "Left");
		ParameterExpression Right = Expression.Parameter(typeof(int), "Right");
		Expression<Func<int, int, bool>> GreaterThanExpr = Expression.Lambda<Func<int, int, bool>>
		(
			Expression.GreaterThan(Left, Right),
			Left, Right
		);
		return GreaterThanExpr.Compile();
	}
	static void Main()
	{
		int L = 7, R = 11;
		Console.WriteLine("{0} > {1} is {2}", L, R, CompileLambda()(L, R));
	}
}

Метод CompileLambda() начинает с создания двух объектов ParameterExpression: один для целого числа Left, а другой для целого числа Right. Тогда статический метод Lambda<TDelegate> в классе Expression используется для создания строго типизированного Expression для необходимого типа делегата. TDelegate для лямбда-выражения имеет тип Func<int,int,bool>, потому что вам нужно результирующее выражение, чтобы принять два целочисленных параметра и вернуть булево значение, основываясь на их сравнении. Обратите внимание, что корень лямбда-выражения получен из свойства GreaterThan класса Expression. Возвращенное значение является подклассом Expression, известным как BinaryExpression, то есть он принимает два параметра. Тип Expression служит в качестве класса фабрики для многих полученных из Expression типов и других членов хелперов. Вот несколько других подтипов Expression, которые вы, вероятно, захотите использовать при программном построении деревьев выражений:

  • BinaryExpression: Add, Multiply, Modulo, GreaterThan, LessThan и так далее
  • BlockExpression: действует как контейнер для последовательности других Expression
  • ConditionalExpression: IfThen, IfThenElse и так далее
  • GotoExpression: для разветвления и возврата к LabelExpressions
  • IndexExpression: для доступа к массиву и свойству
  • MethodCallExpression: для вызова методов
  • NewExpression: для вызова конструкторов
  • SwitchExpression: для тестирования соответствия объекта набору значений
  • TryExpression: для реализации обработки исключений
  • UnaryExpression: Convert, Not, Negate, Increment, Decrement и так далее

Этот список можно продолжать и продолжать. На самом деле, есть более чем 500 методов и свойств, возвращающих десятки типов выражений в этом классе. Они покрывают фактически любую конструкцию кодирования, которую вы можете себе представить (и, вероятно, многие другие, которые вы не можете представить). Сложные деревья выражений могут быть полностью построены из объектов, производных от Expression, когда создаются экземпляры непосредственно из статические свойства и методов этого базового класса.

Чтобы закруглиться с этим вводным разделом, посвященным данной теме, давайте рассмотрим еще один интересный пример. Собранное вручную лямбда-выражение, показанное ранее, является хорошим, но оно обеспечивает только предикаты для целых чисел. Кроме того, оно генерирует код только для операций «больше, чем». Следующий листинг показывает более динамичную версию этого кода, который может быть использован для любого типа данных и различных сравнений.

Листинг 1-9: Класс DynamicPredicate с использование деревьев выражений
using System;
using System.Linq.Expressions;
class DynamicPredicate
{
	public static Expression<Func<T, T, bool>> Generate<T>(string op)
	{
		ParameterExpression x = Expression.Parameter(typeof(T), "x");
		ParameterExpression y = Expression.Parameter(typeof(T), "y");
		return Expression.Lambda<Func<T, T, bool>>
		(
			(op.Equals(">")) ? Expression.GreaterThan(x, y) :
			(op.Equals("<")) ? Expression.LessThan(x, y) :
			(op.Equals(">=")) ? Expression.GreaterThanOrEqual(x, y) :
			(op.Equals("<=")) ? Expression.LessThanOrEqual(x, y) :
			(op.Equals("!=")) ? Expression.NotEqual(x, y) :
				Expression.Equal(x, y),
			x, y
		);
	}
}

Была построена дженерик-функция, чтобы сгенерировать типизированное выражение, основываясь на сравниваемых типах и операциях сравнения с помощью параметра типа и стандартного строкового параметра, соответственно. Функция генерации метко названа Generate. В следующем листинге обратите внимание, как теперь могут быть определены и динамически скомпилированны предикаты для различных типов данных.

Листинг 1-10: Вызов DynamicPredicate
static void Main()
{
	string op = ">=";

	var integerPredicate = DynamicPredicate.Generate<int>(op).Compile();
	var floatPredicate = DynamicPredicate.Generate<float>(op).Compile();

	int iA = 12, iB = 4;
	Console.WriteLine("{0} {1} {2} : {3}", iA, op, iB, integerPredicate(iA, iB));

	float fA = 867.0f, fB = 867.0f;
	Console.WriteLine("{0} {1} {2} : {3}", fA, op, fB, floatPredicate(fA, fB));
	Console.WriteLine("{0} {1} {2} : {3}", fA, ">", fB,
		DynamicPredicate.Generate<float>(">").Compile()(fA, fB));
}

Первый предикат, сгенерированный в этом примере, использует для целочисленных типов оператор «больше-чем-или-равно». Следующим для того же оператора, сравнивая типов с плавающей точкой. Предикаты затем используется для выполнения простых сравнений. В последнем выражении строится динамический предикат для оператора «больше-чем» для типов с плавающей точкой, который используется для сравнения тех же значений с плавающей точкой последнего вызова. Рисунок 1-8 показывает результат выполнения кода.

Рисунок 1-8: Использование класса DynamicPredicate

Мы лишь скользнули по поверхности того, что могут сделать в .NET деревья. Сила LINQ и DLR не была бы возможной без них. Например, LINQ интерфейс IQueryable может быть использован, чтобы «потреблять» динамически собранные выражения, давая вам по-настоящему элегантный способ с легкостью расширять с течением времени поисковые интерфейсы и интерфейсы запросов. Этот пример и другие будут рассмотрены в главе 6. Теперь же давайте взглянем на еще один способ работы с метапрограммированием в .NET.

Метапрограммирование через динамические объекты

Строго типизированные языки задают тон, как говорят, в мире .NET. Даже при том, что реализации IronPython почтенного языка программирования Python впечатляет как с точки зрения производительности, так и совместимости, программисты, которые привыкли работать в стеке Microsoft, все же не так сильно притягиваются к нему, как некоторые из нас надеялись. Это не только потому, что старые привычки отмирают с трудом. Новые навыки довольно трудно сформировать, в частности, когда разработчики считают, что используемые ими инструменты отлично подходят для решения проблем. Попросить C++ и Visual Basic 6 разработчиков усовершенствовать свои навыки, чтобы выучить C# и Visual Basic. NET было бы достаточно сложно. Попросить тех же разработчиков вложить время и энергию, чтобы узнать Python и Ruby, оказалось бы почти невозможным.

Динамическим языкам, как JavaScript, Python и Ruby, есть много чего предложить. Языки сами по себе прекрасно выразительные. Хорошо развитые платформы и библиотеки, как JQuery, Django и Rails, позволяет легко с ними работать. После работы с этими языками некоторое время, мы обнаруживаем, что, кажется, есть бесконечная глубина в подключенных библиотеках. Почти все, о чем вы могли только мечтать при создании многофункциональных приложений, было создано и заложено в стандартные библиотеки.

Увы, динамические языки никогда не смогут стать столь же популярными в .NET Framework, как наши надежные, статически типизированные. Но это не означает, что разработчики, работающие с динамическими языками программирования, не могут поучаствовать в чем-то очень интересном.

Предпосылки динамической типизации C#

25 января 2008 года, Чарли Калверт из Microsoft опубликовал в блоге статью под названием «Future Focus I: Dynamic Lookup". В этом посте он писал: "Следующая версия Visual Studio обеспечит общую инфраструктуру, которая даст всем .NET языкам, включая C#, возможность принимать решения по именам в программе во время выполнения, а не во время компиляции. Мы назвали эту технологию динамическим просмотром (dynamic lookup)".

Итак, в версию 4.0 языка программирования C# была включена хорошая поддержка для создания и обработки динамически типизированных объектов. Чарли также описал ключевые сценарии использования этой новой возможности. Годы спустя его список по-прежнему убедителен. В этот список входят:

  • Взаимодействие с Office и COM объектами
  • Работа с типами, написанными на динамических языках
  • Улучшенная поддержка рефлексии

Мы рассмотрим первые два сценария подробно в главах 8-10 этой книги. Поскольку вы уже почувствовали вкус рефлексии в этой главе, давайте построим на третий сценарий Чарли. Мы начинаем с краткого тура так называемого «утиной типизацией». Эта странно звучащий термин происходит от Джеймса Виткомба Райли, поэта 19-го века: "Когда я вижу птицу, которая ходит, как утка, и плавает, как утка, и крякает, как утка, я называю эту птицу уткой".

Что касается компьютерного программирования, вы можете перевести "ходить, как утка ..." Райли в «если объект поддерживает методы и свойства, я ожидаю, я могу их использовать".

Мы используем слово «ожидаю» намеренно, потому что концепция «утиной типизации» -это все об ожиданиях по времени компиляции по сравнению с временем выполнения. Если вы ожидаете, что объект имеет метод CompareTo, принимая один параметр Object и возвращая целочисленный результат, вам важно, как он туда попал? Ответ зависит в некоторой степени от вашего мировоззрения. Что еще более важно, это зависит от ваших инструментов. Изучите код на рисунке 1-9, который выбрасывает исключение во время выполнения в процессе создания простой сортировки.

Рисунок 1-9: Простая сортировка, которая выбрасывает исключение

L в SOLID

Акроним программирования SOLID имеет много смысла в пяти маленьких буквах. L обозначает принцип подстановки Барбары Лисков (LSP), который звучит немного грозно. Хотя это совсем не сложно понять. Этот принцип обозначает, что подтипы ведут себя как типы, из которых они получены. Неотъемлемой частью LSP является то, что компилятор дает программисту поддержку в соблюдении правильности типа во время компиляции.

Почему это важно – понять дискуссию о типизации? Ну, в строго типизированных языках, таких как C#, классы ведут себя как контракты. Они дают обещания о своих членах, числе, порядке и типах параметров, которые должны присутствовать, а также о возвращаемых типах. Действительно динамические языки не выполняют контракты таким образом, что отличает их для программиста, который привык получать LSP поддержку со стороны компилятора. Это важно иметь в виду, как вы узнаете о возможностях динамической типизации С#.

Код выглядит хорошо, и он компилирует отлично. Но во время выполнения выбрасывается ArgumentException из функции Sort, указывая, что "по крайней мере один объект (в сравнении) должен реализовать IComparable." Если бы вы были новичком в C#, придя из мира Python или Ruby, эта ошибка могла бы привести к реальной путанице. Взглянув на другие C# программы, программист, работающий с динамическими языками, мог бы заключить, что реализация функции CompareTo в классе с ожидаемым методом – это все, что требуется, чтобы сделать сортировку массивов, содержащих этот тип. Однако, реализация функция Sort немного сложнее. Вы не только должны включить метод CompareTo в класс, он должен быть конкретно типа IComparable.CompareTo. Добавим, что одно простое объявление класса Thing решает проблему:

public class Thing : IComparable
{
	public string Name { get; set; }
	public int CompareTo(object other)
	{
		return Name.CompareTo(((Thing)other).Name);
	}
}

Такие требования чужды программистам, которые привыкли работать с динамическими языками, потому что они кажутся им неважными. Почему функция Array.Sort должна думать о том, как метод CompareTo попал в класс Thing? Для них существование функции во время выполнения вполне достаточно.

Нет смысла в религиозных дебатах

Это точка в дискуссии, где мы могли бы по нисходящей спирали отпустить несколько религиозных аргументов о ценности различных моделей программирования, но мы не собираемся поддаваться искушению. Динамический способ программирования не лучше или хуже, чем строго типизированный. Он другой. Некоторые проблемы хорошо решаются тем или иным путем. Суть заключается в том, что разработчики программного обеспечения сделали большую работу как для динамических языков, так и для строго типизированных. Это делает оба подхода достойными изучения и уважения.

Вопрос, который Чарли Калверт и команда, работающая над компиляторами C#, поставили в 2008 году, звучит так: "Может ли современный язык программирования хорошо поддерживать как строго, так и динамические модели типизации"? При всем уважении, ответ на этот вопрос звучит – Нет. C# - это по-прежнему статически типизированный язык. Динамические возможности в версии 4.0 были заперты на стороне языка, а вы сейчас увидите. Объявление и использование динамических объектов в C# вряд ли может быть проще:

dynamic name = "Kevin";
System.Console.WriteLine("{0} ({1})", name, name.Length);

Запустите этот код в LINQPad после выбора C# Statements из выпадающего списка Language, чтобы увидеть, как выводится имя и длина строки как 'Kevin(5)' во вкладке Results. Теперь измените объявленный тип для переменной name на string и запустите его снова. Вы заметите, что результат во вкладке Results будет идентичным. В чем разница? После того как вы определили переменную name как string, переключитесь на вкладку IL в LINQPad. Это покажет вам скомпилированный IL для кода, который будет выглядеть, как на рисунке 1-10.

Рисунок 1-10: IL для вывода строки на консоль

IL мы представим в главе 2, но этот код такой простой, что вы должны быть в состоянии понять, что происходит. Две литеральные строки, которые вы видите в С# коде, ставятся в стек до того, как вызывается метод get_Length класса System.String при помощи callvirt. Если вы никогда не видели IL, вы можете быть удивлены, обнаружив, что свойство Length для строки реализуется с именем get_Length. Результат вызова get_Length имеет тип System.Int32, но метод Console.WriteLine ожидает параметры типа System.Object. Код box используется для представления этого целочисленного типа значения как объекта перед вызовом System.WriteLine. Все это было сделано с op-кодом IL.

Теперь измените тип переменной name обратно на dynamic, перезапустите код и посмотрите на IL. Мы не будем показывать тут IL листинг, потому что это заняло бы несколько страниц, и потребуется еще несколько страниц, чтобы полностью описать его. В главе 8 мы более глубоко изучим класс CallSite и другие соответствующие метапрограммированию классы пространств имен System.Runtime.CompilerServices и Microsoft.CSharp. При просмотре IL обратите внимание на две литеральные строки, которые помещаются в стек и которые не были туда помещены в предыдущем примере:

IL_0012: ldstr "WriteLine"
//... пропущена часть кода ...
IL_0089: ldstr "Length"

Вы можете также заметить, что ссылка на метод get_Length также отсутствует в IL. Как мог быть вызван метод get_Length, если его нет в IL? Тот факт, что он отсутствует, является интересной подсказкой. Кроме того, вы видите литеральные строки для "WriteLine" и «Length» в исходном C# коде? Нет, так почему же эти символьные строки появляются сейчас в IL? Когда тип переменной name был изменен из string на dynamic, произошло много изменений за кулисами, как вы можете видеть.

Внешнее изменение включают преобразование C# компилятором объектов CallSite в IL. CallSite в буквальном смысле – это часть кода, где происходит что-то динамическое. Вас может удивить, что в этом коде есть два объекта CallSite. Один используется для вызова Length для name, который происходит через вызов связывающего метода GetMember во время выполнения:

IL_0089: ldstr "Length"
//... часть кода опущена ...
IL_00AA: call Microsoft.CSharp.RuntimeBinder.Binder.GetMember

Это имеет смысл, учитывая, что переменная name была отмечена как dynamic. С помощью точки после переменной name для вызова свойства Length связующему механизму C# передается литеральная строка "Length» для отображения объекта, чтобы получить значение. Вы помните, что Чарли сказал в своем блоге о том, как динамический просмотр упростил бы рефлексию? Рефлексия, которую механизм связывания C# делает для вас, также объясняет, почему вызов функции get_Length явно отсутствует в этой версии кода. Здесь нет необходимости связывать вызов get_Length время компиляции, поскольку вызов будет происходить в CallSite во время выполнения с помощью рефлексии.

Остающиеся тайны на данном этапе – это (а), литеральная строка "WriteLine", которую вы обнаружили в IL, и (б), второй CallSite, который был выброшен в контейнер. Могут ли они быть связаны? Более того, они связаны между собой. Второй CallSite используется для вызова InvokeMember в связующем механизме C#, чтобы направить динамический вызов статическому методу "WriteLine" класса System.Console. Вопрос, который может появиться в вашем мозгу: «Почему метод WriteLine был вызван динамически"? В конце концов, ничего в System.Console не было объявлено динамическим.

Этот код отражает одну из самых больших проблем, с которой сталкиваются многие разработчики, когда имеют дело с динамической типизацией в C#. Когда вы передаете тип, объявленный как dynamic, методу, или если метод возвращает такой тип, то вызов этого метода также будет осуществляться динамически через CallSite, который создает компилятор. Большинство разработчиков ожидают, что заплатят определенную цену за использование ключевого слова dynamic в C#, но они не ожидают, что это будет иметь волновой эффект, в результате чего другие близлежащие функции и члены также будут вызываться динамически. Лучший совет, который мы можем дать, заключается в том, что нужно быть осторожными при использовании ключевого слова dynamic в C#.

Теперь, когда у вас есть представление о том, что происходит за кулисами с динамической типизации в C#, давайте обратим внимание на простой, но полезный класс, который может быть использован для реализации динамических контейнеров свойств.

В C# нет динамического свойства

Вы можете быть удивлены, обнаружив, что в C# нет никакого поддерживающего типа для ключевое слово dynamic. Функциональность, включенная ключевым словом dynamic, - это умный набор действий компилятора, которые используют CallSite объекты в контейнере локальной области выполнения. Компилятор управляет тем, что программисты воспринимают как динамические ссылки на объект, через эти экземпляры CallSite. Параметры, возвращаемые типы, поля и свойства, которые получают динамическую обработку во время компиляции, могут быть помечены некоторыми метаданными, чтобы указать, что они были созданы для динамического использования, но базовым типом данных для них всегда будет System.Object.

Реализация метаобъектов в C#

Многие динамические языки позволяют, чтобы любой объект мог быть рассмотрен как контейнер свойств. Члены могут быть добавлены или удалены из контейнера, когда нужно. Некоторые динамические языки даже позволяют менять определения классов на лету, так что вновь созданные экземпляры объектов этих типов получат обновленное определение. В Python, например, это просто – добавить свойства к экземпляру на лету, используя следующий код:

>>> class PyExpandoObject():
... pass
...
>>> container = PyExpandoObject()
>>> container.Name = 'Jenny'
>>> container.PhoneNumber = 8675309
>>> print container.Name, '-', container.PhoneNumber
Jenny - 8675309

В этом коде класс PyExpandoObject определяется как пустой класс при помощи ключевого слова pass. Затем экземпляру PyExpandoObject выделяется именованный container. Что происходит дальше, может показаться странным для разработчиков, использующих строго типизированные языки, но это распространено во многих средах метапрограммирования. Добавляются два новых члена Name и PhoneNumber путем присвоения значений их именам. Выражение print используется, чтобы отправить значения новых членов обратно на консоль. Python выводит типы новых членов правильно, что вы можете проверить с помощью Python функции type().

>>> type(container.Name)
<type 'str'>
>>> type(container.PhoneNumber)
<type 'int'>

Внутри Python управляет словарем из его членов, которые он может менять на лету, добавляя новые члены или переопределяя их по мере необходимости. Python даже позволяет программисту программно удалять члены. Добавление новых членов в экземпляр класса в C # 4.0 – аналогично простое действие, если использовать класс ExpandoObject:

dynamic container = new System.Dynamic.ExpandoObject();
container.Name = "Jenny";
container.PhoneNumber = 8675309;
Console.WriteLine("{0} - {1}", container.Name, container.PhoneNumber);

Этот C# код выведет на консоль ту же строку, что делал и предыдущий Python код. В Python любой объект может действовать как динамический контейнер свойств. В C# же вы должны использовать ExpandoObject или встроить функционал в один из ваших собственных классов. Не удивительно, что ExpandoObject использует объект словаря внутри, чтобы имитировать функциональность, которую предлагает Python. Что неясно, однако, это то, как C# компилятор понимает, как взаимодействовать с классом ExpandoObject для включения новых пар имя/значение, чтобы войти во внутренне управляемый словарь.

В приведенном ранее примере с участием динамической строки была вызвана функция GetMember механизма связывания во время выполнения C#, чтобы отразить строку и получить значение ее свойства Length. Функция GetMember была вызвана потому, что вы пытались получить значение свойства Length, чтобы отобразить его на консоли. В предыдущем C# коде при помощи ExpandoObject, присвоение container.Name и container.PhoneNumber явно не собиралось вызывать GetMember в механизме связывания, потому что вы собирались изменить значения, а получить их. Как вы можете себе представить, DLR также включает в себя для этих целей функцию SetMember механизма связывания C#. IL, который устанавливает значение "Jenny" для свойства Name следует данному потоку:

IL_000E: ldstr "Name"
//... пропущена часть кода ...
IL_0039: call Microsoft.CSharp.RuntimeBinder.Binder.SetMember
//... пропущена часть кода ...
IL_0058: ldstr "Jenny"

C# компилятор генерирует вызов SetMember для свойства "Name", чтобы установить значение "Jenny". Компилятор C#, кажется, хорошо справился со своей работой, но вы все еще не знаете, как пара имя/значение ("Name", "Jenny") собирается попасть во внутренний словарь ExpandoObject. Ответ на этот вопрос мы получаем, когда рассматриваем ExpandoObject, который реализует шесть интерфейсов:

  • IDynamicMetaObjectProvider
  • IDictionary<string,object>
  • ICollection<KeyValuePair<string,object>>
  • IEnumerable<KeyValuePair<string,object>>
  • IEnumerable
  • INotifyPropertyChanged

Первым интерфейсом в списке является тот, который включает стандартную функцию механизма связывания C# SetMember, чтобы вызвать пользовательский код для управления объектом внутреннего словаря ExpandoObject. Определение IDynamicMetaObjectProvider выглядит обманчиво просто:

public interface IDynamicMetaObjectProvider
{
	DynamicMetaObject GetMetaObject(Expression parameter);
}

В этом интерфейсе вы начинаете видеть общие термины метапрограммирования, которые вы можете узнать из примеров в данной главе. Вы знаете, что означает динамический. Вы знаете, что такое Expression. Метаобъекты еще не четко определены, но они наверняка являются чем-то, что используется в метапрограммировании.

Слишком много "мета"!

Появление приставки «мета» снова и снова в жаргоне метапрограммирования может быть немного чрезмерным. Это не та приставка, которую мы слишком часто встречаем в обыденной речи. Помните, что в переводе с греческого, «мета» обозначает «после» или «рядом». DLR термин «метаобъект» может обозначать «объект-после» или «объект-рядом». То, как DLR использует метаобъекты в сочетании с механизмами связывания во время выполнения, лучше соответствует «объекту-рядом». Метаобъекты запускаются наряду с другими типами, как ExpandoObject, чтобы помочь механизму связывания в связывании конкретных методов, как SetMember и GetMember, когда код требует установить или получить именованные значения.

При реализации этого интерфейса ExpandoObject может взаимодействовать с механизмом связывания C#, предоставляя обработчики для конкретных событий, которые происходят в жизненном цикле этих типов. DynamicMetaObject, возвращаемый функцией GetMetaObject в интерфейсе, имеет множество виртуальных методов, которые могут быть изменены для предоставления конкретных типов механизму связывания. Все эти методы подробно описаны в главе 8. На данный момент требуются два методы для понимания взаимосвязи между механизмом связывания С# и внутренним словарем ExpandoObject:

  • BindGetMember
  • BindSetMember

Когда механизм связывания отмечает, что динамический объект, с которым он работает, реализует интерфейс IDynamicMetaObjectProvider, он откладывает связующие вызовы методов в DynamicMetaObject, который осуществляется через этот интерфейс, а не пытается решать эту задачу с ними при помощи отражения. Это многоступенчатый процесс звучит немного пугающе, но как только вы начнете использовать его, вы поймете, что это довольно просто и достаточно гибко, чтобы выполнять практически любые сценарии метапрограммирования.

Чтобы не оставалось никаких тайн, давайте реализуем расширяемый контейнер свойств MyExpandoObject, предоставляя пользовательские реализации для GetMember и SetMember во время выполнения. Вместо того чтобы реализовывать весь IDynamicMetaObjectProvider, давайте пойдем по более короткому пути. В .NET Framework был включен вспомогательный класс DynamicObject, который реализует для вас IDynamicMetaObjectProvider, скрывая несколько сложных методов Bind* и вместо этого раскрывая набор более простых методов Try*. Для реализации динамического контейнера свойств вам нужно унаследовать ваш класс от DynamicObject и переопределить функции TryGetMember и TrySetMember для предоставления пользовательского связывающего кода. Следующий листинг показывает определение типа MyExpandoObject.

Листинг 1-11: MyExpandoObject: динамический контейнер свойства
using System;
using System.Collections.Generic;
using System.Dynamic;

public class MyExpandoObject : DynamicObject
{
	private Dictionary<string, object> _dict = new Dictionary<string, object>();

	public override bool TryGetMember(GetMemberBinder binder, out object result)
	{
		result = null;
		if (_dict.ContainsKey(binder.Name.ToUpper()))
		{
			result = _dict[binder.Name.ToUpper()];
			return true;
		}
		return false;
	}

	public override bool TrySetMember(SetMemberBinder binder, object value)
	{
		if (_dict.ContainsKey(binder.Name.ToUpper()))
			_dict[binder.Name.ToUpper()] = value;
		else
			_dict.Add(binder.Name.ToUpper(), value);
		return true;
	}
}

Для этой реализации мы решили, что свойства, вставленные в контейнер, не должны иметь регистрозависимые имена. Например, программисты должны иметь возможность сохранить значение JABBERWOCKY в контейнере свойств и извлечь его позднее как jAbBeRwOcKy. Функция ToUpper строкового класса используется всякий раз, когда свойства устанавливаются и извлекаются из управляемого внутри словаря, содержа пары «имя-значение». Код в следующем листинге показывает, как может быть использован MyExpandoObject.

Листинг 1-12: Использование MyExpandoObject
class TestMyExpandoObject
{
	static void Main()
	{
		dynamic vessel = new MyExpandoObject();
		vessel.Name = "Little Miss Understood";
		vessel.Age = 12;
		vessel.KeelLengthInFeet = 32;
		vessel.Longitude = 37.55f;
		vessel.Latitude = -76.34f;
		Console.WriteLine("The {0} year old vessel " +
			"named {1} has a keel length of {2} feet " +
			"and is currently located at {3} / {4}.",
			vessel.AGE, vessel.name,
			vessel.keelLengthINfeet,
			vessel.Longitude, vessel.Latitude);
	}
}

После создания экземпляра MyExpandoObject и присвоения ссылки на динамическую переменную vessel, свойства различных типов помещаются в контейнер свойств. Каждое присвоение будет вызывать переопределенную реализацию TrySetMember, которая будет помещать их в объект внутреннего словаря. В конце свойства извлекается из контейнера свойств по именам. Для показа регистронезависимой обработки имен свойств им намеренно был присвоен другой регистр, чем ранее. На рисунке 1-11 представлен результат выполнения кода из листингов 1-10 и 1-11 в LINQPad.

Рисунок 1-11: Класс метапрограммирования MyExpandoObject в действии

Этот маленький, поддерживающий метапрограммирование класс делает большую работу по повышению уровня абстракции для управления парами «имя-значение», что делает код очень удобным для многократного использования. Он также увеличивает понимание, обеспечивая естественный интерфейс при одновременном снижении сложности для восприятия.