LXF143:c
|
|
|
- Язык C# Как обойти его ограничения при переносе кода, написанного на C++
Содержание |
C# и Mono: Стереоэффект
- Андрей Кузьменко продолжает рассказ о переносе кода с C++ на C#. В этой статье речь пойдет о множественном наследовании.
Сложилось так, что язык C# не поддерживает множественное наследование. Споры о том, хорошо это или плохо, продолжаются уже долгое время, однако по большей части они принимают вид «религиозной войны» и носят скорее теоретический характер. С другой стороны, при объектно-ориентированном подходе к проектированию программного обеспечения множественное наследование часто оказывается механизмом, наиболее адекватно описывающим моделируемый объект и его поведение. Кроме того, может возникнуть необходимость выполнить реинжиниринг существующего программного обеспечения: например, перенести код, написанный на C++ с использованием множественного наследования, на платформу Mono. В данной статье будет рассмотрен один из возможных вариантов подхода к реализации подобия «настоящего» множественного наследования в языке C#. Хочу сразу предупредить читателя о том, что все те возможности в том виде, как они есть в языке C++, в рамках платформы Mono получить нельзя; однако можно провести адаптацию имеющихся языковых средств и использовать интересные и полезные практические результаты для решения определённого класса задач – а именно, объединения разнородных иерархий классов.
Встать, суд идёт
Множественное наследование позволяет создать производный класс на основе нескольких базовых классов. При этом критики традиционно указывают на две возможные проблемы. Первая заключается в том, что появляется возможность унаследовать одно и тоже же имя поля данных или метода от нескольких базовых классов, что может послужить причиной неоднозначности:
//Родительские классы class First { int function(int x); }; class Second { int function(int x); }; // Дочерний класс class Result : First, Second { } // создан объект класса Result res = new Result(); // какой именно метод будет вызван? res.function(10);
Вторая проблема – это «ромбовидное» наследование, когда в иерархии от базового класса к производному существует более одного пути. Вот классический пример:
class File{ string file_name}; class InputFile : File {}; class OutputFile : File {}; class IOFile : InputFile, OutputFile {};
Возникает вопрос: должны ли поля данных базового класса дублироваться в объекте подкласса столько раз, сколько существует путей между ними в иерархии наследования? В языках, поддерживающих множественное наследование, таких как С++, Eiffel, Python, это решается по-разному.
Справедливости ради замечу, что список трудностей, возникающих при множественном наследовании, не исчерпывается теми двумя, что описаны выше; однако существуют ситуации, когда множественное наследование действительно оправдано – например, объединение независимых иерархий классов, композиция интерфейсов, создание класса из интерфейса и реализации. Наличие механизма множественного наследования делает язык програмирования выразительнее и богаче, и наиболее справедливый путь – это дать выбор программисту: использовать имеющиеся возможности или нет.
Теория
Если класс рассматривается как механизм для представления некоторых сущностей, то интерфейсы можно понимать как описание некоторых действий над этими сущностями. По сути своей, интерфейсы в языке C# очень похожи на виртуальные методы абстрактного класса в языке C++. Они описывают группу связанных функциональных возможностей, которые могут принадлежать любому классу и иметь методы, свойства, события, индексаторы или любое их сочетание. Интерфейсы не могут содержать поля данных. Кроме того, они не содержат реализации методов. Когда говорят, что класс наследует интерфейс, это означает, что класс предоставляет реализацию для всех членов, определяемых интерфейсом. Таким образом, в интерфейсе мы заявляем, что хотим сделать, но не определяем, как это будет конкретно реализовано. Смысл в том, что для разных классов мы реализовываем однотипный набор методов с одним видом их вызова, но при этом реализация методов будет различаться в разных классах. Допускается наследование произвольному числу интерфейсов.
Существующая в языке C# замена «полноценного» множественного наследования классов на наследование интерфейсов выглядит следующим образом: есть базовый класс, который имеет некоторый набор данных и методов. Есть один или несколько интерфейсов, которые предполагают выполнение классом некоторых операций. Производный класс наследует «полноценному» классу, получая от него некоторые данные и методы, плюс принимает на себя обязательства по реализации тех интерфейсов, которым он наследует.
Понятие интерфейса легко иллюстрируется простым житейским примером. Представьте себе множество предметов, относящихся к бытовой электронике: видеомагнитофон, DVD-проигрыватель, CD-плейер, автомагнитолу. Произведённые разными фирмами-изготовителями и выполняющие разные функции, они все имеют унифицированный пользовательский интерфейс: если нажать на кнопку с нарисованным треугольником, устройство будет «играть», а если нажать кнопку с квадратом, воспроизведение прекратится.
Практика
Давайте рассмотрим программную модель мобильного телефона, который может использоваться как MP3‑плейер, для работы с которым пользователю достаточно нажимать соотвествующие кнопки на корпусе.
using System; namespace Gadget { class MobilePhone { public void make_call(string number) { Console.WriteLine(“Call:” + number); } } interface IManagement { void play( ); void stop( ); } class Gadget : MobilePhone, IManagement { public void usb_connect( ) { Console.WriteLine(“Connect to computer…”); } public void play( ) { Console. WriteLine(“Music playing now!”);} public void stop( ) { Console.WriteLine(“Stop playing music.”); } } class Program { public static void Main(string[] args) { Gadget g = new Gadget(); g.play(); g.stop(); g.make_call(“89991234567”); } } }
Страшная тайна
В литературе, будь то учебники для начинающих или справочники для профессионалов, ответ на вопрос о том, почему же в языке C# нет «нормального» множественного наследования для классов, зачастую очень расплывчат. Дескать, множественное наследование сложно для понимания и является источником потенциальных ошибок. Я думаю, что стоит приподнять завесу таинственности и разобраться в этой ситуации.
Выше вы уже прочли, что возможно унаследовать одно и то же имя поля данных или метода от нескольких базовых классов. Как же тут быть? Язык C++ предлагает такое решение: если нет необходимости дублировать информацию в производном классе, то все непосредственные потомки базового класса в ромбовидной схеме должны наследовать своему предку виртуально:
class File{}; class InputFile : public virtual File {}; class OutputFile : public virtual File {}; class IOFile : public InputFile, OutputFile {};
Таким образом, File становится виртуальным базовым классом. Этот вариант, будучи простым и вполне понятным для программиста, требует определённых «трудозатрат» со стороны компилятора. Проблема в том, что виртуальные базовые классы реализуются как указатели. При этом размер кода увеличивается, доступ к полям данных виртуальных базовых классов оказывается медленнее по сравнению с невиртуальными базовыми классами, а сам компилятор, который всё это реализует, становится «тяжелее». Важное замечание: к моменту определения классов InputFile и OutputFile нет никакой информации о том, будет ли когда-нибудь какой-либо класс наследовать от них обоих или нет. Если изначально не объявить класс File виртуальным базовым классом, то может сложиться так, что разработчику класса IOFile потребуется переопределить классы InputFile и OutputFile, а это бывает невозможным по причине того, например, что данные классы находятся в скомпилированной библиотеке.
При этом правила, согласно которым происходит инициализация виртуальных базовых классов, оказываются сложнее, чем в случае «простого» наследования. При невиртуальном наследовании аргументы конструктора базового класса задаются в списке инициализации непосредственного производного класса – таким образом, аргументы передаются совершенно очевидным способом: классы уровня N транслируют аргументы вверх, классам уровня (N–1). А в случае виртуального наследования ответственность за инициализацию базового класса ложится на самый последний дочерний класс в иерархии. По этой причине классы, наследующие виртуальному базовому и нуждающиеся в инициализации, должны знать обо всех своих виртуальных предках. Кроме того, при добавлении в иерархию нового производного класса он обязан принять на себя обязательство по инициализации виртуальных базовых классов. Ко всему прочему, возникает резонный вопрос о способах корректной реализации операций копирования и присваивания в свете вышеописанных проблем. Единственный радикальный выход в сложившейся ситуации – это отказ от полей данных в родительских классах. Таким образом устраняется необходимость в передаче аргументов конструкторам виртуальных базовых классов.
Надеюсь, теперь ясности стало больше. Желающие разобраться в этом вопросе ещё глубже могут целенаправленно продолжить свои поиски, но перед этим мы получим максимум от возможностей платформы Mono. Как именно? Читаем дальше!
Что хотим
Какую функциональность нам бы хотелось иметь, пусть даже в первом приближении? Итак:
- Дочерний класс, наследуя своим родителям, должен иметь возможность вызывать их методы без необходимости для программиста повторно переписывать код. Свобода использования того, что уже есть.
- Мы хотим иметь возможность использовать объекты дочернего класса там, где ожидаются объекты родительских классов. Это позволит нам оперировать производным классом через ссылки на базовые классы. По сути, обычный полиморфизм.
- Родительские классы для реализации механизма наследования не должны подвергаться никаким изменениям. Должен соблюдаться принцип целостности. При соблюдении данного требования мы сможем использовать родительские классы только посредством их интерфейса, что очень актуально при работе с динамическими библиотеками, когда исходный код недоступен.
Моделирование
В качестве предметной области мы будем использовать офисную технику: принтеры, сканеры и многофункциональные устройства (МФУ). Думаю, нет смысла рассказывать что такое «принтер» и «сканер». Что касается МФУ, то это устройства, прежде всего, объединяющие в себе возможности принтера и сканера, так сказать, «два в одном». Кроме того, в некоторых моделях может присутствовать функциональность факса и кард-ридера. МФУ позволяет экономить место на столе и интерфейсные разъёмы, а если присутствует поддержка операционной системы Linux, так и вообще замечательный аппарат!
Принтеры и сканеры в нашем случае символизируют две независимые иерархии, которые мы хотим объединить с целью получения новой сущности – МФУ. Очевидно, что класс принтеров обладает своими специфическими данными и методами, а класс сканеров – своими. Класс МФУ, наследуя классу «принтер» и классу «сканер», получит определённый набор методов от родительских классов, и кроме того, у него будут свои собственные поля данные и методы.
Для программной модели из всего многообразия свойств принтеров мы будем использовать свойство «язык описания стра-ниц» (Page Description Language – PDL), а сканер будет определяться оптическим разрешением. В прилагаемом файле LibDev.cs содержится код классов Printer и Scaner. Типы данных, представляющие собой родительские классы Printer и Scaner, очень просты. Они вынесены в своё собственное пространство имён OfficeDevices. Данный файл компилируется в dll-библиотеку, которая затем используется в основном проекте.
Тип данных «ПРИНТЕР» содержит поле данных page_language, свойство Page_Language с процедурами доступа get и set, конструктор с параметром для инициализации поля данных и метод ShowPageLanguageInfo(), выводящий на консоль значение поля данных. Тип данных «СКАНЕР» – поле данных resolution, свойство Resolution с процедурами доступа get и set, конструктор с параметром для инициализации поля данных и метод ShowResolutionInfo(), выводящий на консоль значение поля данных.
Класс-наследник – MFU (сущность «МНОГОФУНКЦИОНАЛЬНОЕ УСТРОЙСТВО»). Именно он является нашей конечной целью.
Языковые средства
Композиция – это отношение между классами, возникающее тогда, когда объект одного типа содержит в себе объекты других типов. В качестве синонимов термина «композиция» выступают «вложение» или «агрегирование». Пример:
class Address {} class ContactInfo{} class Person { Address address; ContactInfo contact; }
Когда мы говорим о наследовании, то для нас это означает, что «класс является разновидностью другого класса». В термин «композиция» вкладывается смысл «содержит» или «реализуется посредством».
Бывает так, что при разработке программ требуется выполнить приведение одного созданного программистом типа данных к другому. Для решения такой задачи в языке C# предусмотрены средства явного и неявного преобразования типов. Если в программе есть класс First и класс Second и нам нужно выполнять неявное преобразование типа Second к типу First, то в классе First нужно объявить метод приведения следующего вида:
public static implicit operator First(Second s) { // необходимые действия}.
Фактически, выполняется перегрузка оператора.
Кодирование
Шаг первый – создание классов-наследников Scaner_tmp и Printer_tmp.
public class Printer_tmp : Printer { internal MFU mfu_part; public Printer_tmp(string data) : base(data) { } static public implicit operator MFU(Printer_tmp p) { return p.mfu_part; } } public class Scaner_tmp : Scaner { internal MFU mfu_part; public Scaner_tmp(string data) : base(data) { } static public implicit operator MFU(Scaner_tmp s) { return s.mfu_part; } }
Благодаря наследованию классов Printer и Scaner через них можно получить доступ к функциям ShowPageLanguageInfo() и ShowResolutionInfo(), а также к свойствам родительских классов. Мы видим, что каждый из этих классов-наследников имеет поле данных mfu_part и оператор для приведения типа, который позволяет преобразовать эти классы к типу MFU. Зачем это нужно? Для того, чтобы мог выполняться следующий код:
Printer p1 = new Printer(“PostScript”); Printer p2 = new Printer(“PCL6”); MFU p3 = new MFU(“PCL5”, “1200x1200”, “USB 1.1”); Printer [] arr = {p1, p2, p3}; foreach (Printer print_i in arr) print_i.ShowPageLanguageInfo(); Printer_tmp tmp = (Printer_tmp)arr[2]; MFU mfu = (MFU)tmp; mfu.Mfu_SelfTest(); mfu.ShowPageLanguageInfo();
В массиве arr типа Printer содержится три элемента: два из них – это указатели на «настоящие» принтеры, а третий элемент – указатель на объект класса MFU, работа с которым ведётся через указатель на родительский класс Printer. В цикле foreach все элементы массива «считаются» принтерами, однако в какой-то момент времени нам может понадобиться работать с третим элементом именно как с объектом класса MFU, коим он, собственно, и является.
Шаг второй: создание дочернего класса MFU.
public class MFU { Printer_tmp printer_part; Scaner_tmp scaner_part; string interface_type; public string Interface { get { return interface_type; } set { interface_type = value; } } public string Language { get { return printer_part.Page_Language; } set { printer_part.Page_Language = value; } } public string Resolution { get { return scaner_part.Resolution; } set { scaner_part.Resolution = value; } } public MFU(string lang, string res, string iface) { printer_part = new Printer_tmp(lang); scaner_part = new Scaner_tmp(res); printer_part.mfu_part = this; scaner_part.mfu_part = this; interface_type = iface; } public void ShowPageLanguageInfo() { printer_part.ShowPageLanguageInfo( ); } public void ShowResolutionInfo() { scaner_part.ShowResolutionInfo( ); } public void Mfu_SelfTest() { Console.WriteLine(“MFU ready to work!”); } static public implicit operator Scaner(MFU mfu) { return mfu.scaner_part; } static public implicit operator Printer(MFU mfu) { return mfu.printer_part; } }
Поля данных: interface_type – способ подключения устройства к компьютеру. Далее идут printer_part и scaner_part – это то самое агрегирование, которое поможет нам сделать подобие множественного наследования. Интерфейсы для работы с полями данных класса: Interface, Language и Resolution. Методы: ShowPageLanguageInfo() и ShowResolutionInfo() организуют доступ к методам родительских классов. Операторы преобразования типов приводят тип MFU к типам Scaner и Printer. Именно благодаря им станет возможен код вида:
Printer p = new MFU(“PostScript”, “600x600”, “LAN”); Scaner s = new MFU(“PCL6”, “1200X1200”, “USB 2.0”);
Теперь внимательно рассмотрим конструктор класса MFU. Для «связи с предками», т. е. реализации стратегии агрегирования, создаются объекты Printer_tmp и Scaner_tmp. С их помощью мы получим доступ к функциям ShowPageLanguageInfo() и ShowResolutionInfo(), находящимся в классах Printer и Scaner.
Затем у созданных объектов инициализируются поля mfu_part, ссылкой на создаваемый объект типа MFU. Это будет использоваться при операциях приведения типа.
Наконец, инициализируется поле данных interface_type. Посмотрим, как можно использовать полученный код:
data = new List<Printer>(); data.Add(printer1); data.Add(printer2); data.Add(printer3); foreach (Printer print_i in data) print_i.ShowPageLanguageInfo(); } Вывод на консоль: Page language:PostScript Page language:SPL Page language:PCL6
На этом всё. Желаю успешной работы!
Mono: Подключаем библиотеки
Пусть наш файл с именем ProgramLib.cs содержит некий исходный код на языке C#, который мы хотим превратить в динамически подключаемую библиотеку. Файл Program.cs содержит код программы, который в своей работе использует код библиотеки ProgramLib.dll. Набираем в консоли следующие команды:
$ gmcs -t:library ProgramLib.cs $ gmcs -r:ProgramLib.dll Program.cs
Вот таким нехитрым образом обеспечивается работа с динамическими библиотеками в командной строке.