дальше...

Теология ООП (часть II)

19-Nov-2004

Продолжение статьи "Теология ООП"

 

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

Но не сердитесь, мы с вами всего лишь пытаемся найти "срединный путь" в проектировании программ и библиотек.

 

***

Подобно тому, как всякий естественный язык оттачивается и пополняется наиболее интеллектуальными его носителями - писателями и поэтами, язык математики формируется на кончике пера абстрактно-мыслящей элиты - математиков. И поскольку процесс формирования алгебраической нотации длится уже не один век, этот язык становится все более естественным в том же смысле, что и обычные языки. Естественность в некотором приближении есть эстетика плюс краткость.

Но это так, вообще, о языке математики. Так чем в принципе запись

length(s)

оличается от

s.length()

? Вероятно, принципиальных отличий тут нет за исключением того, что первый способ записи выглядит эстетичнее для тех, кто проходил в школе алгебру и учил ее хорошо. Второй же способ - это некая новая нотация, которая, будучи полностью изолированной (или очищенной - как хотите) от традиционной, приводит к таким странным вещам, как i.add(j). А ведь ОО парадигма поначалу вроде бы пыталась нас убедить в том, что все идет именно к этому: всё есть объекты и методы в них, даже числа и операции над ними.

Но постойте, нотация i.add(j) не является ни эстетичной, ни краткой в сравнении с i+j. Конечно, программирование - это уже не математика (или еще не математика?), и оно имеет право вводить собственные абстракции. (Сами математики, к слову сказать, внеся некоторые дополнения и коррективы в уже имевшийся у них язык, получили Lisp, и весьма счастливы.) Вопрос лишь в том, чтобы вновь вводимые абстракции и нотации были достаточно кратки, выразительны, красивы, и что самое главное - заставляли бы думать человека таким же образом.

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

Впрочем, перейдем от предположений к конерктным примерам.

 

***

Существует множество объектных библиотек-оберток для базовых системных сервисов, в том числе и для интерфейсов многопоточного программирования. Обычно сами системные API навязывают нам объектный подход: они как правило создают дескриптор (handle), через который и предполагается манипуляция неким системным объектом, например файлом, графическим элементом или потоком исполнения. Все что остается сделать разработчику "обертки" - это просто инкапсулировать дескриптор внутри класса и затем переписать вызовы системного API в методы. Другими словами, буквально перевести все на язык ООП.

И я тоже рассуждал приблизительно так же, когда описывал классы для многопоточного программирования в PTypes. Класс thread не был исключением, кроме разве что одной тонкости, связанной с запуском потока: дело в том, что системный вызов pthread_create() (или BeginThread() в Windows) не может быть в конструкторе класса, как это часто делается для дескрипторных интерфейсов. Вместо этого, создание дескпритора и запуск асинхронной функции был перемещен в отдельный метод thread::start(). Но дело, собственно, не в этом. Объекты этого класса имеют опцию autofree, которая указывает должен ли объект самоуничтожиться по окончании выполнения асинхронной функции thread::execute().

Так вот, на старых Linux-системах, базирующихся на LinuxThreads, PTypes мог вылетать со странными ошибками порчи памяти (memory corruption). Отладка показала, что LinuxThreads почему-то иногда записывает дескриптор потока в области памяти, в которых когда-то существовал мой объект типа thread, но его в этом месте уже нет. В чем же дело?

Посмотрите на описание pthread_create(). Вы передаете этой функции ссылку на место в памяти, куда система должна записать новый дескриптор потока. По поводу запуска вашей асинхронной функции start_routine не дается никаких гарантий, но вы надеетесь, что она когда-нибудь будет вызвана.

Моя ошибка состояла в том, что асинхронная функция могла быть вызвана и закончена еще до того, как LinuxThreads успевал записать дескриптор в соответствующее поле моего класса. И в случае autofree потока это приводило к порче памяти, поскольку выходило так, что объект удалялся из памяти еще до того, как основной поток вернется из pthread_create(). Интересно, что кроме старых версий Linux ни одна другая система не делала этого в таком порядке, вероятно предвидя потенциальные ошибки вроде моей.

Но если сделать, как говорят киношники, отъезд и взять общий план, то моя ошибка на самом деле происходит от предположения о том, что pthread_create() является конструктором системного объекта. А он таки им не является, или скорее не вписывается в традиционное понятие конструктора, несмотря на внешнюю схожесть. Виной всему - мое стереотипное объектное мышление.

Впрочем, какая-то доля вины тут должна пасть и на разработчиков стандарта POSIX Threads. Размышляя потом над всем этим я также понял, что на самом деле не только pthread_create() не является конструктором, но еще хуже: поток исполнения вообще не является объектом, и следовательно архитекторам POSIX нечего было водить нас за нос своими дескрипторами. Другие функции, использующие дескриптор потока - pthread_join() и pthread_detach() - избыточны и могли быть изъяты из интерфейса (синхронизацию с завершением потока можно реализовать в приложении при помощи семафоров). Если бы создатели PThreads были бы истинными минималистами, то они оставили бы только pthread_create() и уже без того параметра, принимающего дескриптор.

В ядре Linux поддержка потоков долгое время ограничивалась одной-единственной функцией clone(), пока наконец начиная с версии 2.6 публике удалось убедить Линуса Торвальдса ввести полную поддержку POSIX-интерфейса в ядро. Я не в курсе какие при этом приводились доводы, но скорее всего не приводилось ничего путного кроме "POSIX это стандарт". Но стандартный интерфейс может быть таким же несовершенным, как и любой нестандартный, и наоборот. Впрочем, это отдельный разговор.

Конечно же, на уровне приложения иногда полезно описывать классы-обертки для потоков исполнения, но суть вопроса не в этом. Надеюсь уже понятно в чем именно: следует быть осторожнее с ОО-мышлением. И подозреваю, что такая нетривиальная вещь, как потоки исполнения, на самом-то деле оказалась не по зубам объектной парадигме.

 

***

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

В той же самой библиотеке PTypes по пожеланиям пользователей пришлось ввести интерфейс для дейтаграммных сокетов. Недолго раздумывая, я описал два класса ipmessage и ipmsgserver по образу и подобию их поточных эквивалентов ipstream и ipstmserver. (Возможно, для вас непривычен стиль именования в PTypes, но использование этого стиля обосновано: его можно комбинировать с любым другим в одной программе. Если бы PTypes был написан, например, в венгерской нотации, то как минимум один класс программистов - юниксоиды старой школы - были бы жутко недовольны.) В конце концов это всего лишь обертка для системного интерфейса сокетов, и я ничего не пытался менять в этой идеологии.

Сам я никогда не использовал протокол UDP в своих программах, и поэтому развитие этой части библиотеки всегда опиралась на отзывы пользователей. И из этих же самых отзывов я понял, что такой интерфейс для дейтаграмм может легко путать программиста.

Работа с дейтаграммными протоколами сводится к трем основным операциям: посылка пакета, привязка к порту и "слушание" его, получение пакета. Если взять только операцию посылки, то окажется, что для ее осуществления достаточно одной-единственной функции. По сути посылка, если можно так сказать, не имеет состояния, и поэтому нет необходимости описывать класс и размножать объекты; в нашем случае - это класс ipmessage, который таким образом оказывается избыточным. Что касается привязки и получения, то здесь ситуация иная: такой сокет имеет состояние, поскольку система параллельно с вашим приложением должна слушать определенный порт и буферизировать поступающие пакеты. Поэтому, такой класс как ipmsgserver на самом деле нужен, правда даже в нем в принципе есть кое-что лишнее: это методы посылки.

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

 

***

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

И тут мы временами можем сталкиваться с трудностями, связанными с семантической принадлежностью того или иного метода данному классу. Например, принадлежность метода paint() некой абстрактной визуальной компоненте почти не вызывает сомнения. Но как быть с методом, который вставляет данную компоненту в список дочерних элементов родительской компоненты? Должен ли это быть метод add_child() в родительском интерфейсе, или set_parent() в дочернем (или может и то, и другое)? Я бы предпочел первый вариант, но в GUI-интерфейсах чаще встречается как раз таки второй, потому что дочерний объект должен еще и удалить самого себя из списка предыдущего "родителя".

А что если попытаться разрешить проблему выставив этот метод за пределы класса? Представьте себе, вы имеете глобальную функцию set_affinity(parent, child), которая, возможно, вызывает защищенные виртуальные методы оповещения как в родительском классе, так и в дочернем. Не вижу в этом ничего страшного, и даже более того, мне будет легче запомнить тот факт, что set_affinity() никому не принадлежит, чем пытаться вспомнить в каком интерфейсе его следует искать: в родительском или дочернем. И все потому, что set_affinity() по сути никому не принадлежит и является "нейтральным" действием, в котором принимают участие больше одного объекта.

Другой пример: обычно базовые классы, реализующие подсчет ссылок, объявляют по крайней мере два метода: acquire() и release() внутри этого абстракного интерфейса, которые увеличивают или, соответственно, уменьшают счетчик ссылок. Но возникает вопрос: насколько метод release() принадлежит данному объекту? Ведь в результате вызова этого метода объект может быть уничтожен, следовательно он является чем-то нейтральным скорее чем является "собственностью" объекта. В PTypes оба этих метода объявлены глобальными (правда в случае acquire() - только ради симметрии с release()).

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

Кстати говоря, вовсе необязательно, чтобы такие нейтральные функции были объявлены буквально глобальными: вы вполне можете найти для них место в каком-то другом классе, лишь бы это выглядело логично. Например, тот же set_affinity() может оказаться в объекте окна (ведь визуальные компоненты так или иначе "живут" в окне), а пара acquire()/release() - в каком-нибудь class factory, которые нынче очень модны.

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

 

***

У меня в данный момент нет из всего этого готовых конкретных выводов в стиле "следует писать программы так-то, и не следует писать так-то", поскольку любой такой вывод был бы уязвим для критики.

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

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

 

Ваше мнение?

 

В начало блокнота © 2004-2009 Hovik Melikyan

дальше...