отражение жизни в экране монитора (rainman_rocks) wrote,
отражение жизни в экране монитора
rainman_rocks

Category:

Кризис объектно-ориентированного программирования.

Давно хотел про это написать и, наконец, руки дошли.

Повсеместное торжество объектно-ориентированного подхода - это реальность, данная нам в ощущениях.

На мой взгляд, это очевидно и неоспоримо. Подавляющая часть прикладной разработки ведётся на объектно-ориентированных языках и в объектном стиле. Рекрутеры ищут три заветные буквы в резюме[1]. Студентов программерских факультетов натаскивают на святую троицу "наследование-инкапсуляция-полиморфизм", чтоб от зубов отскакивала. Паттерны и рефакторинги считаются за заповеди, помнить их все наизусть (или хотя бы держать потёртые Евангение от Четырёх и томик Фаулера поближе к рабочему столу) - хороший тон.

Короче, всё объектно-ориентированное по умолчанию считается хорошим, а не-объектно-ориентированное - плохим и устаревшим. Сей консенсус плотно укоренён в головах большинства участников игры: от вяльяжного CEO на презентациях до щуплого кодера где-то на просторах бангалорщины. Слава богу, хотя бы ранняя восторжённая риторика на тему "объектный подход разумен и верен, потому что он отражает устройство материального мира" уже практически сгинула. Всем очевидно, что будь мир устроен аналогично объектно-ориентированным средам, письменность, например, никогда бы не была изобретена: человечество ещё на ранних этапах погрязло бы в бесконечных священных войнах, выясняя, как же всё-таки правильно: "ручка.писать(бумажка, текст)" или "бумажка.написать(текст, ручка)"[2].
Поэтому ООП было низведено с постамента Божественного Откровения до статуса "Метода, который Работает", эдакой как-бы-серебряной пули. Тоже неплохое положение, и угроз ему в ближайшее время не видно.

Тогда с чего я вообще завёл разговор о каком-то "кризисе"? А вот как раз ещё раз ухвачусь за пример с письменностью и акцентирую внимание на следующем: в объектном лагере согласия нет. Видны тёрки и непонимания между сторонниками различных конфессий диалектов и вариациий объектного подхода. Можно оценивать это по-разному, но лично мне кажется, что это "жжж" - неспроста.

Видовое разнообразие

О первом очевидном расколе в объектном движении я уже писал раньше, но это было настолько претенциозно, уныло и сумбурно, что, пожалуй, "проще новых наделать". Речь тогда шла о расхождениях между наиболее популярной "классовой" объектной моделью, приехавшей к нам как есть из языка Simula, и более гибкой прототипной моделью наследования, каковая характерна для скриптовых языков. Если Python, например, приложил определённые усилия к тому, чтобы с минимальными жертвами сделать свои объекты визуально совместимыми с Simula-подходом, то JavaScript и Lua заморачиваться не стали и ведут себя не вполне ожидаемым (для выходцев из мира Java/C++) образом. Попытки утрамбовать их в прокрустово ложе классов, наследования и аксесоров выглядят диковато и приводят только к распуханию и усложнению программ, не давая особой выгоды. Тем не менее, прототипная модель ничуть не менее "объектна", чем классовая. Но в ней многие рефакторно-паттерновые подходы начинают видоизменяться или вовсе пробуксовывать. Федот, да не тот.

Вопрос механизмов наследования - он вообще довольно больной. Даже между такими флагманами, как C++ и Java, нету согласия по вопросу множественного наследования и интерфейсов. Масла в огонь подлили дженерики, со своими запутанными правилами ковариантной/контравариантной совместимости. Недавно появившийся и успевший нашуметь гугловский Go напугал традиционалистов отказом от наследования вообще. На периферии маячат упомянутая выше прототипная модель, миксины, экстеншн-методы, multiple dispatch и прочая чертовщина. Как соблюсти чистоту канона (и где вообще этот канон) - непонятно совершенно.

Ещё один, недавно возникший раскол - противостояние "жирной" (rich) и "тощей" (anemic) моделей. В последнее время анемичная модель действительно стала набирать обороты. Казалось бы, ну и фиг - какая разница, это ведь всего лишь вариация на ту же самую объектную тему?.. Сейчас я попробую объяснить, почему мне это кажется серьёзной идеологической бомбой под фундаментом "классического" ООП.

Вторичность половых признаков

Что такое ООП? Ожидаемый ответ, опять-таки: "наследование-инкапсуляция-полиморфизм". Три красивых длинных слова, отражающих некие абстрактные концепции, конечно, добавляют своего мистического флёра. Но я всё-таки рискну заметить, что суть ООП совсем не в этом. И у меня есть доказательства.

Начнём по порядку. Наследование, как выяснилось, бывает очень разное и вообще необязательно. Его доволно легко имитировать. Смотрите сами, код:
class MyBase {
    public MyBase(int param) {
        // do constructor
    }
    public void MyMethod(String param) {
        // do something;
    }
}

class MyChild extends MyBase {
    public MyChild(param) {
        // do constructor 2
    }
    // other methods
}



по сути оказывается почти эквивалентен[3] коду следующему
interface MyBase {
    public void MyMethod(String param);
}

class MyParent implements MyBase {
    public MyParent(int param) {
        // do constructor
    }
    public void MyMethod(String param) {
        // do something;
    }
}

class MyChild implements MyBase {
    MyParent _base;
    public MyChild(param) {
        MyParent _base = new MyParent(param);
        // do constructor 2
    }
    public void MyMethod(String param) { return _base.MyMethod(param); }
    // other methods
}

Т.е. наследование класса ("is-a") по сути есть комбинация реализации ("is-a") интерфейса и включения ("has-a") родительского объекта, которому "делегируется" обработка не-переопределённых методов. Как бы синтаксический сахар для такой комбинации.

Собственно, наследование, по факту, примерно так и реализуется C++/Java, если предствавить, что _base является не ссылкой, а типом-значением, а вместо пробрасывающих ("делегирующих") обёрток для MyMethod работает виртуальная таблица методов. (А в Tcl/Snit оно вообще реализуется В ТОЧНОСТИ так.)

Поскольку "интерфейс" суть проявление полиморфизма - мы таки свели наследование к полиморфизму. Он, вообще говоря, тоже иногда существует вполне себе отдельно от ООП (см. полиморфные операторы в раннем Perl), но его пока отложим на потом.

С последним элементом, инкапсуляцией всё гораздо проще. Она попросту необязательна как таковая. Это продемонстрировал Python и другие скриптовые языки: отсутствие ограничений на доступ к полям и методам вовсе не приводит к катастрофам, ибо если разработчик таки добрался до недокументированного метода - скорее всего, он уже разобрался в коде и понимает, что он делает.

Сорвав лишнюю терминологическую шелуху, поглядим на объект и вспомним, что он всё-таки из себя представляет: это всего лишь объединение данных и кода для работы с ними.

На все руки мастер

Объединив в себе структуру данных (кортеж) и набор функций, объект стал заменителем, с одной стороны, для кортежа, с другой стороны, для модуля. В PHP и Java, например, где другие средства организации модульности и пространств имён практически отсутствуют[4], класс вообще стал синонимом заменителем понятия "модуль". А модульность - это очень полезный подход, который позволяет делать код более читабельным и поддерживаемым.

С одной стороны, казалось бы, это чисто терминологический вопрос. Ну слили структуру и модуль вместе, получили более универсальную единицу организации кода - вроде нормально.

С другой стороны, выяснилось, что даже после такого слияния классы стремятся расползтись в разные стороны, согласно первоначальным предназначениям: возникают "классы-модули", напичканные огромным количеством методов, в т.ч. статических, и склонные к злоупотреблению паттерном Singleton; и "классы-кортежи", содержащие в себе только поля и набор тупых геттеров-сеттеров[5]. Всё это называется "анемичная модель", о которой уже упоминалось выше.

Анемичная модель диктуется практикой. Она решает упомянутую выше проблему "ручка.писать(бумажка, текст) VS бумажка.писать(текст, ручка)". В самом деле, основное отличие метода от процедуры в том, что 1) один из аргументов передаётся как "this" и для него позволен доступ к приватным атрибутам;  2) метод приписывается к определённому классу (т.е. пространству имён). Пункт 1, при условии отказа от инкапсуляции, перестаёт быть критичным. А для пункта 2 отнюдь не всегда разумно выбирать класс одного из аргументов, гораздо чаще здравый смысл подсказывает использовать для этого некий другой класс-"модуль". Всё это подозрительно напоминает старый добрый процедурный подход (с точностью до полиморфизма, но о нём см. ниже).

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

Ещё раз о полиморфизме

Вышеприведённые рассуждения подталкивают нас к страшной мысли: единственное концептуальное и практически полезное отличие объектного программирования от комбинации "процедурное + модульность" в конечном сводится к наличию полиморфизма - т.е. способности объектов по-разному реагировать на сообщаемые им "сигналы" (вызовы методов).

Чтобы понять, в чём заключается особая магия полиморфизма, подумаем (как и в случае с наследованием), как бы мы его могли имитировать при помощи подручных средств.

Итак, рассмотрим следующий пример:
interface List {
    public int getLength();
}

class ArrayList {
    public int getLength() {
        // blah-blah вернуть хранимую длину
    }
    // blah-blah длина массива и ссылка на данные
}

class LinkedList {
    public int getLength() {
        // blah-blah побежать по узлам и сосчитать
    }
    // blah-blah ссылка на первый узел
}

Он реализуется в процедурном языке примерно как-то так:
struct {
    int typeCode;
    void* object;
} List;

const TYPE_ArrayList = 0;
const TYPE_LinkedList = 1;

int ArrayList_getLength(ArrayList* this) {
    // blah-blah вернуть хранимую длину
}

int LinkedList_getLength(LinkedList* this) {
    // blah-blah побежать по узлам и сосчитать
}

int List_getLength(List this) {
    if (this.typeCode==TYPE_ArrayList) return ArrayList_getLength((ArrayList*)this.object);
    if (this.typeCode==TYPE_LinkedList) return LinkedList_getLength((LinkedList*)this.object);
    // blah-blah ошибка типа
}

Любой уважающий себя программист, конечно, перепишет последнюю функцию так:
int List_getLength(List this) {
    switch (this.typeCode) {
        case TYPE_ArrayList:
            return ArrayList_getLength((ArrayList*)this.object);
        case TYPE_LinkedList:
            return LinkedList_getLength((LinkedList*)this.object);
    }
    // blah-blah ошибка типа    
}
А хитрый программист вообще постарается использовать таблицу:
(int(void*))[] VTABLE_getLength = {ArrayList_getLength, LinkedList_getLength};

int List_getLength(List this) {
    VTABLE_getLength[this.typeCode](this.object);
}

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

Если мы выстроим все возможные способы реализации полиморфизма в ряд, по возрастанию их "удобства", получим: if < switch/case < таблица < ООП.

Теперь поразмыслим: какое качество возрастает в этом ряду слева направо? Ответ прост: декларативность. Т.е. отсутствие необходимости делать все проверки и переходы руками. switch/case избавляет от повторения названия проверяемой переменной, таблица избавляет от явного повторения вызова, а поддержка полиморфизма на уровне языка избавляет от необходимости явно указывать таблицу.

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

Конечно, как показывает пример SQL или чисто-функциональных языков[6], декларативность накладывает свои ограничения: вы не можете контролировать вычисления напрямую и вынуждены прибегать к фокусам для реализации некоторых достаточно типовых, но "непредусмотренных" данным декларативным подходом вещей. Но, как правило, декларативность всё-таки представляет собой благо.


Выводы

Что-то я написал, написал, пора и закругляться. Суммируем поток сознания:

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

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


Примечания.

1 ^ Личный опыт общения по телефону: "так, а с ООП вы хорошо знакомы?" - "ну вот же, в резюме, два года опыта на C#, это ведь объектный язык, там просто по-другому нельзя!" - "ну вобщем-то верно, но... всё-таки, знаете ли вы ООП?!"

2 ^Было бы ещё радикальное движение, продвигающее подход "текст.написаться(бумажка, ручка)", но оно было бы заклеймлено опасной ересью сразу.

3 ^Интересные эффекты происходят, если MyMethod в базовом классе вызывает какие-то другие виртуальные методы объекта, которые в потомке переопределены; но это, вообще, довольно нездоровый способ организации кода и вместо него лучше прибегать к более явному Dependecy Injection.

4 ^Пакэджи в жабе в конечном итоге сводятся к классам, а неймсмпейсы в пхп появились только совсем недавно.

5 ^Наличие в Java прямого доступа к полям объекта и отсутствие properties - это вообще цырк какой-то. Они вообще высокоуровневый язык делали или где?!

6 ^Функциональное программирование является частным случаем декларативного, я настаиваю!
Tags: общефилософское, программизм
Subscribe

  • И на фантаста проруха, а куда ж без неё

    Вот ЛЛео наш Каганов, конечно, хороший человек, но, бывает, спорет что-нибудь уж совсем ни в какие ворота. Например, в недавном посте на тему…

  • Про транспорт

    Если посмотреть на автомобиль беспристрастно - это чудесное устройство, которое ни одно здравомыслящее общество не стало бы терпеть. Артур Кларк,…

  • О рельсовых идиотах

    Рука болит (RSI), буду краток. Гитхаб поломали. Потому что программисты на рельсах дебилы:…

  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic
  • 115 comments

  • И на фантаста проруха, а куда ж без неё

    Вот ЛЛео наш Каганов, конечно, хороший человек, но, бывает, спорет что-нибудь уж совсем ни в какие ворота. Например, в недавном посте на тему…

  • Про транспорт

    Если посмотреть на автомобиль беспристрастно - это чудесное устройство, которое ни одно здравомыслящее общество не стало бы терпеть. Артур Кларк,…

  • О рельсовых идиотах

    Рука болит (RSI), буду краток. Гитхаб поломали. Потому что программисты на рельсах дебилы:…