Введение в генерацию программного кода

Генерация объектно-ориентированного кода. Технология CodeDom

Показывать лекцию целиком

Объектная модель документов кода

В данной лекции мы рассмотрим еще одну технологию Microsoft для генерации кода - CodeDom (Code Document Object Model). На русский язык это название переводится как объектная модель документов кода. Технология CodeDom позволяет строить модель кода, используя набор объектов. Впоследствии провайдер кода может конвертировать эту модель в программный код на конкретном языке, вроде C# или Visual Basic или другой язык из семейства языков .Net. Применение CodeDom отличается от применения шаблонов или программ, напрямую манипулирующих текстом для генерации кода. Пространство имен CodeDom является набором классов, представляющих понятия объектно-ориентированных языков программирования семейства .Net. Например, CodeDom предоставляет классы для создания переменных, классов, массивов, циклов и других составляющих объектно-ориентированного кода. В CodeDom нельзя генерировать любую текстовую информацию, за исключением прямой вставки текста применением класса CodeSnippetExpression, да и то в ограниченном контексте.

Применение CodeDom

Технология CodeDom позволяет генерировать код на нескольких языках. Для генерации кода необходимо создать дерево объектов из пространства имен System.Codedom, предоставляющее классы для отражения в языково-независимом стиле большинства программных конструкций. Эти объекты представляют собой синтаксические конструкции языков программирования, вроде объявления классов и переменных, циклов, условий, присваиваний, вызовов методов и т.д. Утверждается, что его применение позволяет не отвлекаться на нюансы разработки каждого языка программирования и быть уверенным, что CodeDom обработает все правильно. Утверждается также, что такая абстракция позволяет лучше структурировать код, позволяя при необходимости внесения изменений не беспокоиться об уже созданном коде. Однако далее мы увидим, что на практике вышеизложенное не всегда верно.

Самым верхним, или корневым объектом дерева является CodeCompileUnit. Он является абстрактным контейнером для хранения всех остальных объектов генерируемого кода.

//Создание объекта верхнего уровня дерева CodeDom
CodeCompileUnit compileUnit = new CodeCompileUnit();
    

После создания дерева объектов используется объект CodeProvider, имеющийся у каждого языка среды .Net. В пространстве имен Microsoft.CSharp имеется класс CsharpCodeProvider, который содержит метод GenerateCodeFromCompileUnit, генерирующий код из объекта CodeCompileUnit. Для языка Visual Basic используется соответственно метод GenerateCodeFromCompileUnit класса VBCodeProvider из пространства имен Microsoft.VisualBasic.

Кроме метода GenerateCodeFromCompileUnit имеются методы для генерации кода из пространства имен (GenerateCodeFromNamespace), класса (GenerateCodeFromMember), выражения (GenerateCodeFromExpression), оператора (GenerateCodeFromStatement) и других конструкций. Пространство имен System.CodeDom.Complier определяет интерфейс ICodeGenerator, который может быть использован для вывода сгенерированного кода в объект TextWriter.

В примерах этой лекции мы продемонстрируем, как добавлять директивы импорта, объявлять классы, методы, переменные, создавать циклы, массивы. Все эти примеры в совокупности являются одним большим примером, код которого и будет приведен после объяснения всех особенностей применения CodeDom. Также будет приведен код сгенерированного кода на языках C# и Visual Basic.

Инициализация пространства имен

Для инициализации пространства имен в сгенерированном коде мы можем использовать следующий код:

//Инициализация объекта пространства имен с заданием имени
CodeNamespace MyGenericNameSpace = new CodeNamespace("MyNameSpace");
compileUnit.Namespaces.Add(MyGenericNameSpace);

//Импортирование пространств имен
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System"));
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System.Text"));
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System.Collections.Generic"));
    

Этот код создает в генерируемом коде новое пространство имен, и импортирует в него пространства имен System, System.Text, а также System.Collections.Generic. Класс CodeNamespace используется для представления пространства имен, которое следует создать, в данном примере создается объект с именем MyNameSpace. Класс CodeNamespaceImport используется для представления пространства имен, которое следует импортировать. В нашем случае создается одно и импортируются три пространства имен.

Создание класса

Следующим этапом является построение класса. Для представления класса используется объект типа CodeTypeDeclaration. Класс CodeTypeDeclaration также позволяет создавать перечисления, структуры, интерфейсы. Однако по умолчанию создается класс с атрибутом public. Пример создания класса:

//Объявление класса
CodeTypeDeclaration MyGenericClass = new CodeTypeDeclaration("MyClass");
MyGenericNameSpace.Types.Add(MyGenericClass);
    

В первой строчке генерируется класс с именем MyClass. Этот класс впоследствии может расширяться методами, свойствами, событиями и т.д. Во второй строчке добавляем его объявление в пространство имен MyGenericNameSpace.

Иерархия классов CodeDom

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

(рис 6.1) Иерархия классов CodeDom

Создание метода

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

//Создание точки входа
CodeEntryPointMethod MyGenericMethod = new CodeEntryPointMethod();
MyGenericClass.Members.Add(MyGenericMethod);
    

CodeEntryPointMethod представляет собой точку входа в программу - того места, с которого начинается выполнение программы. Для языка C# это будет метод Main. По умолчанию создается метод с атрибутами public и static. Для создания же обычного метода следует использовать класс CodeMemberMethod и также добавлять объект метода в свойство Members класса. Обязательным параметром будет имя нового метода.

Создание переменных, массивов и списков

Теперь займемся созданием непосредственно выполняемых команд, а начнем с инициализации переменных:

//Объявление типа List<string>
CodeTypeReference MyGenericListType = new CodeTypeReference(typeof(List<string>));
//Создание списка типа List<string> с именем MyList
CodeVariableDeclarationStatement MyGenericListVariable = 
    new CodeVariableDeclarationStatement(MyGenericListType,"MyList");
//Создание объекта позволяющего вызывать конструкторы
CodeObjectCreateExpression MyObjectCreateExpression =
    new CodeObjectCreateExpression();
//Установка вызова конструктора
MyObjectCreateExpression.CreateType = MyGenericListType;
MyGenericListVariable.InitExpression = MyObjectCreateExpression;
MyGenericMethod.Statements.Add(MyGenericListVariable);
    

CodeTypeReference представляет собой тип данных в CodeDom. Его можно объявить заранее, чтобы впоследствии не делать этого при каждом объявлении переменной. В данном случае мы объявляем тип List<string>. Каждый язык из среды .Net имеет свои наименования типов данных из типов .Net. Например, имеется тип System.Int32 из среды .Net. Этот тип в C# и Visual Basic может называться по-разному. В CodeDom используется общее имя типа из среды .Net.

CodeVariableDeclarationStatement позволяет объявить переменную. В параметры передаются тип List<string> и имя MyList.CodeObjectCreateExpression представляющее выражение для создания нового объекта. В свойстве CreateType мы указали, что нужно создать новый объект с типом List<string>. А в качестве выражения инициализации переменной в свойстве InitExpression указали заранее созданное выражение.

Написанный выше пример создает такой код:

System.Collections.Generic.List<string> MyList = new System.Collections.Generic.List<string>();
    

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

//Создание переменной temp с типом string
CodeVariableDeclarationStatement MyTempVariable =
    new CodeVariableDeclarationStatement(typeof(System.String), "temp");
MyTempVariable.InitExpression = new CodeMethodInvokeExpression(
    new CodeTypeReferenceExpression("System.Console"),
    "ReadLine", new CodeArgumentReferenceExpression());
MyGenericMethod.Statements.Add(MyTempVariable);
    

В данном примере объявляется строковая переменная temp, которой впоследствии присваивается значение, считанное с консольной строки. Класс CodeMethodInvokeExpression, как следует из названия, служит для создания выражения, вызывающего метод какого-нибудь класса. CodeTypeReferenceExpression указывает тип или класс, метод которого нужно вызвать. Далее мы передали название метода "ReadLine", который хотим вызвать. CodeArgumentReferenceExpression представляет собой ссылку на параметры метода.

Таким образом, генерируется следующий код:

string temp = System.Console.ReadLine();
    

Допустим, что надо создать подобный код:

int MyCount = System.Convert.ToInt32(temp);
    

Это можно сделать следующим образом:

//Создание переменной MyCount с типом int
CodeVariableDeclarationStatement MyCountVariable =
    new CodeVariableDeclarationStatement(typeof(System.Int32), "MyCount");
MyCountVariable.InitExpression = new CodeMethodInvokeExpression(
    new CodeTypeReferenceExpression("System.Convert"),
    "ToInt32", new CodeArgumentReferenceExpression("temp"));
MyGenericMethod.Statements.Add(MyCountVariable);
    

Создание цикла

Рассмотрим код, создающий цикл:

//Создание цикла
CodeVariableDeclarationStatement MyVariable = new CodeVariableDeclarationStatement(typeof(int), "i");
MyVariable.InitExpression = new CodeSnippetExpression("0");
CodeIterationStatement forLoop = new CodeIterationStatement();
forLoop.InitStatement = MyVariable;
CodeAssignStatement assignment = new CodeAssignStatement(
  new CodeVariableReferenceExpression("i"),
  new CodeSnippetExpression("i + 1"));
forLoop.IncrementStatement = assignment;
forLoop.TestExpression = new CodeSnippetExpression("i < MyCount");
MyGenericMethod.Statements.Add(forLoop);
    

CodeIterationStatement используется для создания цикла for. CodeSnippetExpression подставляет блок текста в генерируемый код. Через свойство цикла InitStatement назначается условие инициации переменной цикла. А IncrementStatement применяется для задания условия увеличения счетчика переменной цикла. CodeAssignStatement назначает переменной новое значение. При этом CodeVariableReferenceExpression выводит имя переменной в генерируемый код. Через свойство цикла TestExpression назначается условие, при котором выполняется цикл.

Если мы сгенерируем код в таком виде, то содержимое цикла у нас будет пустым. Добавим несколько операторов в свойство Statements цикла:

CodeBinaryOperatorExpression operate = new CodeBinaryOperatorExpression(
       new CodePrimitiveExpression("item "),
       CodeBinaryOperatorType.Add,
       new CodeVariableReferenceExpression("i"));
forLoop.Statements.Add(new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("MyList"),
       "Add"),operate));
    

Внутри цикла через CodeMethodReferenceExpression создается вызов метода Add. При этом в параметры передается выражение CodeBinaryOperatorExpression, представляющее собой бинарную операцию над двумя другими выражениями. Через перечисление CodeBinaryOperatorType указываем, что нам нужно сложение, то есть Add. В первой части выражения будет стоять строка "item ", а во второй части - переменная i.

Аналогично добавим вывод на консоль значения элемента списка:

CodeExpression expr = new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("System.Console"), 
       "WriteLine"), 
   new CodeArgumentReferenceExpression("MyList[i]"));
forLoop.Statements.Add(expr);
    

CodeArgumentReferenceExpression служит для ссылки на выражение аргумента функции. В завершение в свойство Statements нашего метода добавим еще код для считывания клавиши:

CodeExpression readkey = new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("System.Console"),
       "ReadKey"),
   new CodeArgumentReferenceExpression(""));
MyGenericMethod.Statements.Add(readkey);
    

Вывод результата генерации в файл

Мы рассмотрели возможности построения дерева CodeDom, однако нужно еще сгенерировать код и сохранить его. Для этого используется метод GenerateCodeFromCompileUnit провайдера:

private static void WriteGeneratedFile(CodeCompileUnit compileUnit, string lang)
{
    string path = @"C:\Users\agpx\Desktop\Code Generation\Code Generation Lections\examples CodeDom\MyGenericExample";
    CodeDomProvider provider;
    if (lang == "C#")
    {
        provider = new CSharpCodeProvider();
        path += ".cs";
    }
    else
    {
        provider = new VBCodeProvider();
        path += ".vb";
    }

    using (StreamWriter sw = new StreamWriter(path, false))
    {
        IndentedTextWriter tw = new IndentedTextWriter(sw, "    ");
        provider.GenerateCodeFromCompileUnit(compileUnit, tw, new CodeGeneratorOptions());
        tw.Close();
    }
}
    

Соберем все вместе

А теперь посмотрим на созданную нами программу, объединим все предыдущие примеры и получим следующий результат:

//Создание дерева CodeDom
CodeCompileUnit compileUnit = new CodeCompileUnit();

//Инициализация объекта пространства имен с заданием имени
CodeNamespace MyGenericNameSpace = new CodeNamespace("MyNameSpace");
compileUnit.Namespaces.Add(MyGenericNameSpace);

//Импортирование пространства имен
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System"));
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System.Text"));
MyGenericNameSpace.Imports.Add(new CodeNamespaceImport("System.Collections.Generic"));

//Объявление класса
CodeTypeDeclaration MyGenericClass = new CodeTypeDeclaration("MyClass");
//MyGenericClass.Attributes = MemberAttributes.Static;
//MyGenericClass.TypeAttributes = System.Reflection.TypeAttributes.NotPublic;
//MyGenericClass.IsClass = true;
MyGenericNameSpace.Types.Add(MyGenericClass);

//Создание точки входа
CodeEntryPointMethod MyGenericMethod = new CodeEntryPointMethod();
MyGenericClass.Members.Add(MyGenericMethod);

//Объявление типа List<string>
CodeTypeReference MyGenericListType = new CodeTypeReference(typeof(List<string>));
//Создание списка типа List<string> с именем MyList
CodeVariableDeclarationStatement MyGenericListVariable = 
    new CodeVariableDeclarationStatement(MyGenericListType,"MyList");
//Создание объекта позволяющего вызывать конструкторы
CodeObjectCreateExpression MyObjectCreateExpression =
    new CodeObjectCreateExpression();
//Установка вызова конструктора
MyObjectCreateExpression.CreateType = MyGenericListType;
MyGenericListVariable.InitExpression = MyObjectCreateExpression;
MyGenericMethod.Statements.Add(MyGenericListVariable);

//Создание переменной temp с типом string
CodeVariableDeclarationStatement MyTempVariable =
    new CodeVariableDeclarationStatement(typeof(System.String), "temp");
MyTempVariable.InitExpression = new CodeMethodInvokeExpression(
    new CodeTypeReferenceExpression("System.Console"),
    "ReadLine", new CodeArgumentReferenceExpression());
MyGenericMethod.Statements.Add(MyTempVariable);

//Создание переменной MyCount с типом int
CodeVariableDeclarationStatement MyCountVariable =
    new CodeVariableDeclarationStatement(typeof(System.Int32), "MyCount");
MyCountVariable.InitExpression = new CodeMethodInvokeExpression(
    new CodeTypeReferenceExpression("System.Convert"),
    "ToInt32", new CodeArgumentReferenceExpression("temp"));
MyGenericMethod.Statements.Add(MyCountVariable);

//Создание цикла
CodeVariableDeclarationStatement MyVariable = new CodeVariableDeclarationStatement(typeof(int), "i");
CodeIterationStatement forLoop = new CodeIterationStatement();
MyVariable.InitExpression = new CodeSnippetExpression("0");
forLoop.InitStatement = MyVariable;
CodeAssignStatement assignment = new CodeAssignStatement(
  new CodeVariableReferenceExpression("i"),
  new CodeSnippetExpression("i + 1"));
forLoop.IncrementStatement = assignment;
forLoop.TestExpression = new CodeSnippetExpression("i < MyCount");
MyGenericMethod.Statements.Add(forLoop);

CodeBinaryOperatorExpression operate = new CodeBinaryOperatorExpression(
       new CodePrimitiveExpression("item "),
       CodeBinaryOperatorType.Add,
       new CodeVariableReferenceExpression("i.ToString()"));
forLoop.Statements.Add(new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("MyList"),
       "Add"),operate));

CodeExpression expr = new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("System.Console"),
       "WriteLine"),
       new CodeArrayIndexerExpression(
           new CodeVariableReferenceExpression("MyList"),
           new CodeVariableReferenceExpression("i")
           ));
forLoop.Statements.Add(expr);

CodeExpression readkey = new CodeMethodInvokeExpression(
   new CodeMethodReferenceExpression(
       new CodeVariableReferenceExpression("System.Console"),
       "ReadKey"),
   new CodeArgumentReferenceExpression(""));
MyGenericMethod.Statements.Add(readkey);

WriteGeneratedFile(compileUnit, "C#");
WriteGeneratedFile(compileUnit, "VB");
    

Результирующий код на C# и Visual Basic

На языке C# будет сгенерирован следующий код (без автоматически добавленных при генерации комментариев):

namespace MyNameSpace
{
    using System;
    using System.Text;
    using System.Collections.Generic;


    public class MyClass
    {

        public static void Main()
        {
            System.Collections.Generic.List<string> MyList = new System.Collections.Generic.List<string>();
            string temp = System.Console.ReadLine();
            int MyCount = System.Convert.ToInt32(temp);
            for (int i = 0; i < MyCount; i = i + 1)
            {
                MyList.Add(("item " + i.ToString()));
                System.Console.WriteLine(MyList[i]);
            }
            System.Console.ReadKey();
        }
    }
}
    

Как видим, чтобы сгенерировать одну строчку кода, нужно написать от 5 и более строчек кода генератора. Однако такая, казалось бы неэффективная, затрата труда может компенсироваться тем, что генератор можно применить для создания гораздо большего объема кода, чем код самого генератора.

На Visual Basic код будет выглядеть так (без автоматически добавленных при генерации комментариев):

Option Strict Off
Option Explicit On

Imports System
Imports System.Collections.Generic
Imports System.Text

Namespace MyNameSpace

    Public Class [MyClass]

        Public Shared Sub Main()
            Dim MyList As System.Collections.Generic.List(Of String) = New System.Collections.Generic.List(Of String)
            Dim temp As String = System.Console.ReadLine()
            Dim MyCount As Integer = System.Convert.ToInt32(temp)
            Dim i As Integer = 0
            Do While i < MyCount
                MyList.Add(("item " + i.ToString()))
                System.Console.WriteLine(MyList(i))
                i = i + 1
            Loop
            System.Console.ReadKey()
        End Sub
    End Class
End Namespace
    

Хоть сформированный код на Visual Basic не содержит синтаксических ошибок, не спешите внедрять его - он не будет работать корректно. Это объясняется тем, что правила создания форм на Visual Basic и C# отличаются. И даже когда сгенерированный код на одном языке работает прекрасно, если его создать на другом, то этот код может работать не так, как ожидалось, либо вообще не работать. Это открывает нам следующий недостаток CodeDom - если необходимо выполнять генерацию для нескольких языков, нужно очень тщательно учитывать различия между ними и стараться применять такие программные конструкции, которые являются общими для этих языков.

Компиляция кода

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

string path = @"C:\Users\agpx\Desktop\Code Generation\Code Generation Lections\examples CodeDom\MyGenericExample";
string outputexe = path + ".exe";
CodeDomProvider provider;
if (lang == "C#")
{
    provider = new CSharpCodeProvider();
    path += ".cs";
}
else
{
    provider = new VBCodeProvider();
    path += ".vb";
}
//Установка параметров для выполнения компиляции
 CompilerParameters cp = new CompilerParameters();

 //Добавление ссылки на сборку
 cp.ReferencedAssemblies.AddRange( new string[] {"System.dll", "System.Data.dll", "System.Drawing.dll", 
     "System.Windows.Forms.dll", "System.XML.dll"});

 //Уставка необходимости генерации исполняемого файла, а не библиотеки
 cp.GenerateExecutable = true;

 //Установка имени и пути файла, который будет генерироваться
 cp.OutputAssembly = outputexe;

 //Сохранить сборку как физический файл, а не в памяти
 cp.GenerateInMemory = false;

 //Запустить компиляцию
 CompilerResults cr = provider.CompileAssemblyFromDom(cp, compileUnit);
    

Преимущества

Каковы же преимущества применения CodeDom? Они следующие:

  • Технология CodeDom предоставляет не зависящую от языка реализации единую модель для представления структуры программного кода.
  • Поддержка генерации на нескольких языках. Так как для представления программного кода применяется одна модель, вывод сгенерированного кода можно осуществлять для множества языков, поддерживающих технологию CodeDom. Это означает также то, что генератор можно внедрять и для еще не созданных языков, которые появятся в будущем. Внедрение поддержки CodeDom в будущих языках будет достаточным для возможности генерации.
  • Динамическая компиляция и запуск программного кода. В пространстве имен CodeDom имеются классы, позволяющие динамически компилировать программный код в исполняемые файлы или библиотеки. Также можно динамически запускать скомпилированные программы.
  • По мере развития технологии CodeDom есть вероятность появления возможности конвертировать исходный код программ из одного языка в другой. Например, из C# в Visual Basic и наоборот.
  • Недостатки

    Имеются следующие недостатки применения:

  • Трудоемкость создания генератора. Для генерации одной строчки кода может потребоваться написать около десятка строк довольно непростого кода. Но этот недостаток можно уменьшить, группируя наборы стандартных операций по созданию кода в функции и процедуры.
  • Не все характеристики кода поддерживаются, кроме тех, которые являются общими для всех языков. Например, нет вложенных пространств имен, нет поддержки списков переменных в объявлении переменных, нет поддержки модификатора unsafe в C#. Хотя эти ограничения можно обойти применением класса CodeSnippetExpression, но такой прием сильно уменьшает гибкость генерации для разных языков.
  • Нет гарантии, что сгенерированный код будет компилироваться. После генерации нужно убедиться, что получившийся код корректен.
  • Поведение сгенерированного кода на разных языках может быть разным, вплоть до того, что на одном языке код будет компилироваться, а на другом - нет. И даже если на всех языках код скомпилируется успешно, нюансы поведения сгенерированного кода на разных языках могут отличаться.
  • Программы с применением CodeDom не обладают хорошей наглядностью шаблонов. Выполнение поиска нужного участка шаблонного кода является сложным занятием.
  • Сгенерировать код можно будет только в том стиле и формате, который встроен по умолчанию в CodeDom. Если же нужно создать код, стиль которого отличается от стандартно генерируемого, то придется самостоятельно писать расширения для CodeDom.
  • Заключение

    Если есть необходимость генерировать один и тот же код сразу на нескольких языках, то CodeDom может оказаться весьма полезным инструментом. Эта технология активно используется самой компанией Microsoft. Особенно часто она применяется в Visual Studio, где при выполнении каких-либо действий в зависимости от выбранного языка автоматически создаются объекты и программный код. Теоретически, есть возможность генерировать код для любого языка, для которого существует провайдер CodeDom. Провайдер также включает поддержку компиляции сгенерированного кода, при этом набор объектов CodeDom конвертируется напрямую в MSIL.

    CodeDom поддерживает любой язык, для которого есть провайдер. Разработчики также могут добавить новый язык, создав для него свой провайдер.

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

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

    При применении CodesDom следует:

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