Залепа №9. Microsoft друзей не признает.
Начну с цитаты из одной полезной книжки Алена И. Голуба "Правила программирования в С и С++" (речь идет именно о C++):
Как-то раз я видел интерфейс, в котором объект "календарь" позволял пользователю интерактивно выбирать дату, щелкая мышью на каком-либо из дней, показанных на изображении календаря. "Календарь" затем экспортирует эту дату в другие части программы, помещая ее в объект "дата", который возвращается из сообщения get_date(). Проблема здесь в том, что проектирование выполнено наизнанку. Программист мыслил структурными категориями, а не объектно-ориентированными.
При выполнении должным образом единственным видимым в других частях программы объектом был бы объект "дата". "Дата" использовала бы объект "календарь" для реализации сообщения "инициализируй_себя" (которое могло бы быть конструктором), но "календарь" бы содержался внутри "даты". Определение класса "календарь" можно было бы даже вложить в определение класса "дата". Объект "дата" также мог бы поддерживать другие инициализирующие сообщения, такие как "инициализируй_себя_от_редактируемого_ввода" или "инициализируй_себя_из_строки", но во всех случаях объект "дата" отвечает за нужное для инициализации взаимодействие с пользовательским интерфейсом. Остальная часть программы просто бы непосредственно использовала "дату"; никто, кроме "даты", даже бы не знал о существовании объекта "календарь". То есть вы бы объявили "дату" и приказали ей себя инициализировать. Затем вы можете передавать объект "дата" всюду, куда необходимо. Конечно, "дата" должна также уметь себя вывести, переслать в файл или из файла, сравнить себя с другими датами и так далее.
Другими словами, правильное проектирование в ООП - это создание классов, АБСОЛЮТНО НЕЗАВИСИМЫХ от других классов и объектов, инкапсулирующих все необходимые для работы классы внутри себя. Только такой тип проектирования позволяет создавать действительно переносимый код, который без всякой адаптации можно встроить в свою программу.
Следуя описанному выше работа с классом "дата" должна выглядеть примерно так:
CDate cd = new CDate();
// создали объект "дата" с текущей датой внутри
cd = new CDate("15.06.2007");
// инициализировали строкой
cd = new CDate(15, 6, 2007);
// инициализировали числовыми значениями
cd = my_date;
// инициализировали другим объектом класса CData (присваивание)
cd.SaveToFile("c:\\autoexec.bat");
// сохранили дату в файл
cd.LoadFromFile("c:\\autoexec.bat");
// прочи дату из файла
int days = cd - new CDate("28.02.2006");
// получили количество дней между датами
cd += 365;
// получили дату, на год большую исходной
int dw = cd.DayOfWeek();
// вернуло номер дня недели
cd.ShowCalendar();
// отображение календаря для ввода даты пользователем.
... ну и так далее.Естественно, класс CDate должен быть порожден от System.Windows.Control или иметь некий другой механизм, чтобы (при необходимости) без проблем встраиваться в пользовательский интерфейс. Оцените удобство такого контрола в сравнении с предлагаемым аналогом из библиотеки .NET. Думаю, не надо доказывать, что описываемый здесь на порядок удобнее для программиста. Вот это и есть грамотное, правильное проектирование в стиле ООП.
Но я просто так в этом блоге ничего не пишу. Давайте посмотрим, получится ли с помощью "самой передовой" технологии нормально реализовать нечто подобное описанному.
Итак, упростим задачу до предела:
- есть класс CDate, представляющий ни что иное как обычную календарную дату.
- есть класс CCalendar, представляющий собой вспомогательное окно, отображающее календарь и принимающее ввод от юзера.
Требования к реализации:
- 1) все операции касающиеся даты, должны выполняться в классе CDate. И это есть логично.
- 2) класс календаря, фактически являясь окном, имеет только "оконную" функциональность. Т.е. никаких собственных вычислений дат и периодов он не делает - у нас для этого есть CDate. И это тоже есть логично.
- 3) не забываем про инкапсуляцию, т.е. пользователь нашего класса CDate НЕ должен иметь доступ к функциям внутренней обработки. Для него предоставлен интерфейс взаимодействия в виде открытых свойств и методов, им пусть и пользуется.
Отсюда вытекают пункты:
- 3а) класс календаря не должен быть виден пользователю. Более того, пользователь не должен даже догадываться о том, что CDate использует внутри еще что-то.
- 3б) проект должен иметь некую "модульную" структуру, чтобы встраивание класса CDate в реальную программу было максимально простым.
Что ж, давайте немного углубимся и попробуем осмыслить, как все это будет работать.
Из третьего пункта следует, что объекты класса CCalendar будут создаваться не пользователем, а только классом CDate, им же они будут контролироваться в течении всей жизни календаря и, в конце-концов, он же их будет и уничтожать. Хорошо. Идем дальше.
Судя по первым двум пунктам, наши объекты CDate и CCalendar будут активно вызывать методы друг друга. Причем, скорее всего случится так, что календарю потребуется доступ к некоторым методам CDate, которые предназначены для внутренних механизмов самого CDate. Такое вполне вероятно.
К чему я веду? А к тому, что тут мы уперлись головой в очередной косяк C# - отсутствие в языке понятия "дружественности". :(
В C++ мы бы просто сделали класс CCalendar другом класса CDate, разрешив тем самым календарю использовать внутренние механизмы в виде вызовов защищенных методов CDate. Согласен, решение не самое элегантное, но, поскольку оба класса разрабатываются нами, а пользователи о таком "разделении труда" даже не подозревают (и соответственно не смогут, например, породить наследников от календаря), то такое решение вполне приемлемо.
Выигрыш от него на лицо: календарь может использовать скрытые возможность CDate, при этом пользователь все так же остается ограниченным рамками предоставленного нами открытого интерфейса CDate (пункт 3 выполняется в полной мере).
Что же мы имеем в шарпе?
А там мы лишний раз убеждаемся в том, что технология .NET - это технология, в которой ничего хорошего NЕТ! В данном случае, в ней НЕТ дружественности. И поэтому, чтобы открыть доступ календарю к скрытой внутренней функциональности CDate, нам придется открыть эту функциональность и для всех остальных. Подозреваю, что именно поэтому классы .NET набиты таким большим количеством лишних методов и обработчиков - мелкомягкие вступили в собственную ловушку.
Чем грозит такое "открытие внутренней функциональности" наверное объяснять не нужно: вот есть класс, вот его методы, причем ОТКРЫТЫЕ методы, но пользоваться ими нельзя, т.к. они предназначены для внутренних нужд. Гы-гы-гы! :)
Получается чисто майкрософтовский подход: уверен, что каждому не раз попадались в мелкомягких библиотеках методы, свойства и (особенно!) константы которые "не реализованы" и "введены для дальнейшего расширения" и "будут реализованы в следующих версиях". Смех да и только!
Ладно, время покажет, может все же я зря смеюсь. Давайте лучше закончим с нашим примером.
Пункты 3а и 3б в C# так же вызывают некоторые проблемы. А именно, если распространять свой класс CDate в виде исходников, то он потянет за собой и класс CCalendar, а мы бы не хотели, чтобы пользователь знал о нем. Если же скомпоновать наш класс в сборку (assembly), то класс календаря можно сделать ненаследуемым, но при таком подходе пользователю уже не удастся сделать программу из одного exe-файла - придется тягать за собой еще и нашу сборку.
Сборки - это здорово по отношению к DLL, но убого по отношению к обычным библиотечным файлам, которые до .NET существовали в любом компилирующем языке и функции из которых встраивались прямо в exe-шник, не создавая никаких проблем. Причем, встраивалась не вся библиотека, а только функции/классы, реально использующиеся в приложении. В случае же сборок (как и в случае DLL), нам приходится тянуть за собой мегабайты сборочного барахла даже если мы используем из нее только одну функцию, размером в 300 байт. Почему майкрософт отказалась от использования библиотек объектных модулей мне лично не понятно.
Но возможно они еще вернут такую возможность. По крайней мере у меня есть повод так думать. А откуда взялась такая уверенность напишу в одном из следующих постов.
Урок информатики для мелкософта (пояснение к залепе №8)
Давайте поговорим о С++ и его синтаксисе. В частности о "сокращенной" записи некоторых операций. Слово "сокращенной" я взял в кавычки неспроста. Дело в том, что это сделано не только (и не столько) для того чтобы избавить пользователя от написания длинных строк. Хотя, учитывая майкрософтовскую маниакальную тягу к длинным идентификатором, эти сокращенные формы действительно дают прирост производительности программиста. :)
Ладно, шутки в сторону. Давайте о серьезном.
Язык С (да и его потомок - С++) спроектированы таким образом, чтобы (при использовании НОРМАЛЬНОГО компилятора) программист мог еще на этапе разработки программы управлять полученным машинным кодом. Удивлены? Я тоже не сразу поверил.
Язык С разрабатывался как альтернатива ассемблеру (по этому поводу заранее попрошу не флудить в комментах), т.к. серьезные программы писать на асме в то время стало уже слишком сложно. Другие языки высокого уровня генерировали слишком медленный и прожорливый машинный код и не позволяли программисту управлять этой самой генерацией. Выходило, что чтобы получить оптимальное по скорости и памяти решение (например драйвер устройства или часть ядра ОС) необходимо использовать только ассемблер, напороть ошибок в котором проще простого.
Собственно это и послужило причиной создания нового языка, который должен был отвечать определенным требованиям, примерно таким:
- был бы достаточно высокоуровневым, т.е. содержать конструкции высокоуровневых языков;
- был бы достаточно низкоуровневым, т.е. имел бы (насколько это возможно) команды, аналогичные типовым командам процессора;
- позволял бы получать машинный код, хотя бы сравнимый с ассемблерным вариантом;
- позволял бы программисту (хотя бы частично) управлять генерацией получаемого кода.
Именно исходя из этих требований, в языке С есть, например:
- команды инкремента/декремента (а++, а--), как аналоги соответствующих команд ЦП;
- возможность указать, что переменная должна храниться в регистре процессора, а не в памяти;
- работа с указателями;
- возможность управления размещением переменных и полей структур в памяти;
- команды выделения/освобождения памяти;
- команды поразрядных сдвигов;
- типы, сходные с аппаратными типами (поддерживаемыми процессорами);
- типы unsigned;
- возможность использования любого простого типа в качестве логического (bool);
- отсутствие встроенного строкового типа (т.к. такой тип не поддерживался ни одной платформой);
- и т.д и т.п.
Я слышу, как самые нетерпеливые читатели уже кричат, что многие языки поддерживают все это. Верно, поддерживают! Но это сейчас. А в то время таких возможностей не было ни в одном языке высокого уровня. Возьмите документацию по FORTRAN, COBOL, ALGOL...
Короче, С разрабатывался как полноценная, достаточно высокоуровневая (!) замена ассемблеру. Точка. Естественно и логично, что С++ унаследовал все эти возможности плюс добавил к ним ПОЛНОЦЕННУЮ поддержку ООП. Далее речь пойдет именно о С++.
Экскурс в историю окончен. Пришло, наконец, время объяснить, в чем же различия между полной и сокращенной записью команд и почему одно вовсе не соответствует второму.
Итак, рассмотрим механизм работы выражения c = a + b (естественно, что все три переменные имеют один и тот же тип и для данного типа определена операция сложения). Значение переменной a складывается с значением переменной b и результат заносится в переменную c. Вроде бы все просто. Но эта простота существует только до тех пор, пока наши переменные имеют простой тип. Давайте заменим их объектами и посмотрим, что получится.
А получится следующее:
- для объекта a будет вызван метод T operator+(const T&) const, которому в качестве параметра будет "скормлен" объект b;
- этот метод что-то сделает (например прибавит одно к другому) и создаст временный объект типа Т для хранения результата;
- затем метод вернет ссылку на созданный временный объект (назовем его "объектом D");
- будет вызван метод объекта с: T& operator=(const T&), т.е. ни что иное, как оператор присваивания;
- этот метод и приведет объект с в соответствие с состоянием объекта D, переданного в качестве аргумента.
- Временный объект D будет уничтожен.
Вот именно так работает вся эта, казалось бы простая, кухня. На что нам нужно обратить внимание в первую очередь?
Первоочередную важность тут имеют не вызовы методов и не махинации с объектами, а тот факт, что наше простейшее и казалось бы безобидное выражение c = a + b приводит к автоматическому (скрытому от наших глаз) созданию и уничтожению объекта со всеми вытекающими отсюда накладками!
Может показаться, что создание и уничтожение временного объекта-пустышки хоть и связано с лишней тратой системных ресурсов, но все же не так уж и страшно, особенно при теперешних-то мощностях. Но тут я хочу напомнить, что создание объекта - это не только выделение памяти под сам объект, но и создание и инициализация всех его членов-полей (которые, в свою очередь, тоже могут быть объектами), а также выполнение конструктора этого объекта и всех конструкторов всех его предков. Соответственно уничтожение объекта - это вызов деструкторов объекта и всех деструкторов всех его предков плюс уничтожение всех его членов-полей, которые тоже могут быть объектами. Получается не так уж и мало.
Ну, а если этот объект представляет собой, например, таблицу из базы данных, который, вдобавок ко всему сказанному, при инициализации будет устанавливать соединение с этой самой БД и загрузку данных из нее, отъедая не только память и процессорное время, но еще и канал связи? Подумайте, такими ли скромными могут оказаться на практике эти "накладные расходы".
Ладно, давайте перейдем к "сокращенной" записи описанной выше операции и подумаем вот над чем: теоретически методы operator+() и operator+=() должны делать одно и тоже, различие лишь в том, куда направится результат. Получается, что метод operator+=() можно "вывести" из метода operator+() (как и сделано в C#), но зачем же тогда создатели языка С++ сделали возможность перегрузки обоих методов для каждой операции? Только лишь для того, чтобы программист мог написать различные варианты, оптимизированные для обоих случаев? Это вряд ли.
Уверен, что дальше можно уже ничего не говорить, т.к. настоящие кодеры уже поняли к чему весь этот длинный пост и, вставляя трясущейся рукой сигарету в рот, судорожно прокручивают в мозгу все ужасные последствия применения этого "выведения сокращенного оператора из полного" по схеме "а'ля Шарп". Но все же я разжую ситуацию, дабы и не сильно понятливые осознали всю дикость такого "выведения" и поняли, что при словах "Шарп" и "Майкрософт" рукам есть отчего затрястись.
Итак, по образцу и подобию предыдущих абзацев, разложу все по полочкам. Берем выражение a += b и препарируем его:
- для объекта a будет вызван метод T& operator+=(const T&), которому в качестве параметра будет "скормлен" объект b;
- этот метод что-то сделает с объектом а (изменит его состояние ) и вернет ссылку на объект а.
Всё! Никаких созданий лишних объектов, никаких удалений, никаких присваиваний, ни-че-го! Надеюсь теперь понятно, почему не стоит в С++ делать такие "заглушки":
{ return this->operator=(this->operator+(b)); };
или
что одно и тоже.
Разница в потреблении ресурсов, конечно, впечатляющая, но вроде не такая сильная, чтобы заставить руки так сильно колотиться. Наверное я сгустил краски. Что ж, это действительно так... но только для С++. Теперь давайте рассмотрим, к чему приведет такая же (автоматически генерируемая компилятором) заглушка в шарпе.
Отличие шарпа от С++ (в данном случае) в том, что наши переменные a и b являются на самом деле не "хранителями" объекта, а всего лишь ссылками на него. Вроде бы разницы в результатах быть не должно, но если бы так было на самом деле, то в С++ создатели не вводили бы лишний тип - ссылки. Ладно, смотрим!
Для пояснения ситуации приведу кусок кода из "Залепы №8" (далее все написано на C#):
MyCls a = new MyCls();
MyCls b = new MyCls();
MyCls c = a; // (1)
a.x = 4; b.x = 6; // (2)
Console.WriteLine("Before: A={0}, B={1}, C={2}", a.x, b.x, c.x);
a += b; // (3)
Console.WriteLine("After: A={0}, B={1}, C={2}", a.x, b.x, c.x);
Итак, по-пунктам:
- в строке (1) переменная c становится "дублем" переменной a, т.к. теперь ссылается на тот же объект в памяти, на который ссылается a;
- после инициализации в строке (2), значение c.x так же как и значение a.x становится равным 4 (они же ссылаются на один и тот же объект)
- после выполнения сгенерированного компилятором оператора operator+=() происходит что-то очень странное - связь переменных a и c внезапно разрывается, ибо теперь они указывают на совершенно разные объекты! Это подтверждается вторым выводом значений переменных. Разве этого мы добивались командой сложения? Разве наше сложение не должно просто изменить объект, на который ссылается переменная a и всё???
А происходит такое именно из-за созданной компилятором описанной выше заглушки. Получается, что оператор сложения создал временный объект с результатом (помните "объект D"? ), ссылка на который и записалась в нашу переменную a. А переменная c об этом создании до сих пор ничего не знает и (что самое страшное) НИКОГДА не узнает, ибо для исправления этого "улучшительно-упрощающего" косяка никаких средств в языке нет.
Вот так и будет у нас болтаться в памяти фантом (на который ссылается переменная c), который должен быть давным-давно уничтожен, а правильнее - просто откорректирован еще в методе operator+=(), создание которого майкрософт возложило на компилятор и (внимание!) запретило программистам. Поэтому, если вам захочется самостоятельно перегрузить метод operator+=() или его аналоги для других операций, то просто попейте воды, сделайте несколько глубоких вдохов и расслабьтесь - вам перегрузить эти методы не удастся.
Ну что, Вам все еще хочется "самой перспективной технологии будущего"? Мне уже нет. :(
Залепа №8. Крах технологии от обычного int'а.
Все-таки вредно не спать по ночам! Лежал вот, думал. Пытался вникнуть в суть технологии, подстроиться как-то, понять то, что все еще понять не могу, приспособиться и начать (наконец!) получать удовольствие от ее использования. Ведь тысячи программистов пишут под .NET программы и вовсе не страдают от этого, не ощущают никаких неудобств. Вокруг только и слышно: "За .NET будущее", "Самая удобная система", "Передовое решение". Наверное все же я слишком тупой, что не вижу всех этих прелестей.
Вот такие мысли роились у меня в голове и нароили в конце-концов одну идею, которую я и решил тут же опробовать. Даже из постели вылез. На дворе пятый час ночи, а я тут .NET на прочность проверяю! :)
Как я и ожидал, технология мой простейший экзамен с треском провалила! Супер-простое приложение (30 строчек) сработало правильно, но АБСОЛЮТНО НЕЛОГИЧНО. Но, поскольку на дворе глубокая ночь, то описывать происходящее я не буду, просто приведу код программы. Пусть это послужит читателям этаким ребусом, разминкой для мозгов, домашним заданием.
Посмотрите программу, и решите, какой именно она выдаст результат. Потом откомпилируйте ее и сравните реальные ответы с теми, которые вы ожидали. Думаю будет интересно :)
using System;
using System.Collections.Generic;
using System.Text;
namespace GluckTest
{
class MyCls
{
public int x;
public static MyCls operator +(MyCls a, MyCls b)
{
MyCls rez = new MyCls();
rez.x = a.x + b.x;
return rez;
}
}
class Program
{
static void Main(string[] args)
{
MyCls a = new MyCls();
MyCls b = new MyCls();
MyCls c = a; // теперь А и С указывают на один и тот же объект
a.x = 4; b.x = 6;
Console.WriteLine("Before: A={0}, B={1}, C={2}", a.x, b.x, c.x);
a += b;
Console.WriteLine("After: A={0}, B={1}, C={2}", a.x, b.x, c.x);
Console.ReadLine();
}
}
}
Если Вы смогли предсказать верный ответ, то значит Вы очень неслабо разбираетесь в происходящем и дальше Вам читать нет смысла. У меня только один вопрос: считаете ли Вы такое поведение ЛОГИЧНЫМ и УДОБНЫМ для разработчика???
Кстати, в C++ чтобы добиться такого же "эффекта" надо быть оче-е-е-ень глупым программистом! Да и отловится он там моментально, как только вы уничтожите "лишний" объект. А вот в C# причиной является не программист, а именно сама технология и над исправлением этой аномалии придется поломать голову.
Майкрософт уже не один год продвигает .NET (и, конечно же C#) как "более легкую и удобную" среду для разработки приложений, которая "лишена недостатков C++". Приведенный пример, на мой взгляд, очень наглядно показывает, что в C# могут быть неудобства и пострашнее чем в С++.
Немного подумав, можно понять, что программа все же работает правильно и никаких глюков/ошибок тут нет. Но НЕ ДАЙ БОГ вам наступить на такие грабли в вашей реальной работе!
Мой пример, конечно, синтетический. Но давайте мысленно заменим класс MyCls на какой-нить нормальный класс с кучей полей, свойств, интерфейсов, закрытых и открытых методов, а так же "зароем" переопределение операции сложения куда-нибудь в пра-пра-пра-родителя этого класса. А теперь представьте, что Вам необходимо встроить этот класс в свой проект и сцепить его в плотный узел с десятками других классов и тысячами объектов....
Естественно результат работы Вашей программы будет сильно отличаться от ожидаемого и придется потратить массу времени и сил, чтобы разобраться в ситуации и понять в чем дело. И будет очень хорошо, если дружба с отладчиком и вырывание волос на голове прекратится менее чем за неделю. Иначе есть нехилый риск стать пациентом местной психиатрической клиники.
