ООП, ФП, параллелизм и смена парадигмы

Времена меняются. Сегодня уже зашатался фундамент под казавшейся незыблемой парадигмой - объектно-ориентированным программированием. Буч, Рамбо и Джекобсон, создатели UML, Страуструп, автор С++, Ларман, Фаулер и "банда четырех" - Гамма, Хелм, Джонсон и Влиссидес, авторы шаблонов проектирования, - все они еще недавно казались многим абсолютными и непререкаемыми авторитетами. Но вот спираль развития технологий делает очередной виток, и на сцену выходят не только забытые, а многим даже и не знакомые за давностью лет идеи, теории, личности. Какие проблемы не смогли решить технологии мейнстрима? Что подталкивает исследователей к поиску других парадигм и инструментов? В каком направлении может пойти развитие современных технологий?

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

Например, язык ассемблера был проще машинного языка, потому что не требовал от программиста запоминания десятков кодов машинных инструкций, а заменил их на мнемонические команды. Язык Си был проще ассемблера в том, что автоматизировал типовые алгоритмические конструкции, отвлекавшие программиста от основной задачи: циклы, условные переходы, ввод-вывод и т. д. Кроме того, Си сделал программы переносимыми, не требующими знания различных языков ассемблера для различных архитектур ЭВМ.

Однако уже очень скоро оказалось, что Си - все еще слишком сложный язык программирования. Построение на нем более высоких абстракций, чем понятия "процедура", "подсистема", "структура данных", оказалось достаточно сложным делом. Косвенным подтверждением этому является то, что Си в конечном итоге занял нишу в разработке операционных систем и драйверов, где основные абстракции как раз и выражаются в указанных выше терминах. Ядра всех наиболее популярных операционных систем написаны на Си, а это все семейство MS Windows, Unix, Linux и MacOS X.

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

Но даже в те несколько десятилетий, когда ООП безраздельно властвовал умами программистов, все равно оставались задачи, где языки вроде С++ или Java вчистую проигрывали своим конкурентам. Обратите внимание, от SQL до сих пор не избавились и в современных ORM-фреймворках. Yacc все еще незаменим при разработке новых языков. От Matlab-а не откажутся ученые. Язык R до сих пор лидер в статистических расчетах. Если раньше таких задач было относительно немного, то сегодня их количество растет. Более того, на первый план сегодня выходит совершенно иной тип задач - распараллеливание алгоритмов. Связано это, в основном, с тем, что широкое распространение на настольных компьютерах получили многоядерные процессоры. И для повышения производительности ресурсоемких приложений необходимо переходить от однопоточного к многопоточному программированию. И, значит, очень желательно было бы иметь такие абстракции, которые максимально облегчили бы разработку параллельного кода.

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

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

Возросла ли потребность в параллельных реализациях алгоритмов? Однозначно да! Стало ли их разрабатывать легче? Абсолютно нет!

Думаю, многие упрекнут меня в максимализме. Как же J2EE, .NET, OpenMPI, OpenCL и т. д. и т. п., ведь все эти фрэймворки имеют достаточно высокоуровневые примитивы синхронизации и управления параллельными вычислениями? На мой взгляд, в этих технологиях на откуп программисту оставляют слишком много низкоуровневых деталей, от которых давно пора абстрагироваться.

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

Вот здесь-то ООП и перестает быть самым выгодным решением. Эта парадигма основана на императивном стиле программирования, который сложно приспособить к параллельным вычислениям. Достаточно взглянуть хотя бы на развитие библиотеки графических интерфейсов Qt, написанной на С++. В версии 3 еще много методов было помечено как потоконебезопасные. В версии 4 уже с гордостью говорят о том, что значительное количество методов стали безопасны для параллельного выполнения. Это говорит о том, что разработка потокобезопасного производительного кода является нетривиальной задачей, и поэтому обычно разработанный код небезопасен для параллельного выполнения.

Впрочем, давайте спустимся с небес на землю и посмотрим хотя бы на свои собственные исходники. Как много у вас объектов, чьи методы могут безопасно выполняться параллельно? И если много, то сколько сил вы потратили на то, чтобы не пропустить в нужном месте нужную блокировку, сколько времени вы провели в отладке, когда потоки вошли в "гонку" за взаимно блокируемые ресурсы? Императивный стиль сложен для параллельного программирования.

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

Самый простой пример декларативного языка - HTML. Когда вы описываете web-страницу, вы не говорите, что за чем делать. Вы не говорите, что вначале необходимо отрисовать левый <div>, затем правый, потом все изображения, потом запустить java-script'ы. Вы указываете только то, что в конце концов хотите получить, а браузер уже самостоятельно решает, как именно это сделать. Имея такое декларативное описание страницы, браузеру намного проще выполнить параллельную отрисовку элементов, чем если бы имелся классический алгоритм последовательной генерации элементов. В целом, декларативный стиль программирования предоставляет компилятору широкие возможности для оптимизации.

В наибольшей степени декларативность свойственна функциональным языкам программирования. Назову для примера несколько: различные диалекты Lisp (Common Lisp, Scheme, Clojure), Haskell, Erlang, OCaml (и его брат-близнец для .NET - F#). Код на этих языках больше походит на набор определений, нежели на алгоритм, какой мы привыкли видеть в Си.

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

Рассмотрим пример, который, возможно, многим покажется знакомым.

#include <stdio.h>
int G = 0;
int mul2(int x) {
 G = 1 - G;
 return (G? x*2 : x*5);
}
int main(int argc, char* argv) {
 printf("%d\n", mul2(3) + mul2(4));
 printf("%d\n", mul2(4) + mul2(3));
 return 0;
}

Программа выведет два числа: 26 и 23. С первого взгляда это неверно, ведь в функции main() выводится сумма двух результатов функции mul2(), а сумма должна быть коммутативна. Выходит, что от перемены мест слагаемых сумма все-таки меняется? Разумеется, дело не в сумме, а в том, что функция mul2() завязана на изменяемую глобальную переменную. С алгоритмической точки зрения этот "костыль" вполне может работать корректно. Однако использование такой техники может нарушить всю логику абстракций, используемых в программе, что приведет к глупейшим ошибкам.

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

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

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

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

Дмитрий БУШЕНКО,
d.bushenko@gmail.com

Версия для печатиВерсия для печати

Номер: 

30 за 2010 год

Рубрика: 

Software
Заметили ошибку? Выделите ее мышкой и нажмите Ctrl+Enter!