немного о проектировании интерфейсов

Apr 21, 2012 01:24

Давно уже ничего не писал, а тут зуд в известном месте заставляет немного размять мозги и занять их тем, для чего они (мои мозги) предназначены- попрограммировать.


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

struct Object
{
int IntegerProperty;
string StringProperty;
};

Object FindObjectById(ObjectId id);

Все у нас хорошо. По запросу получаем plain структуру, содержащую свойства. Далее возникает необходимость расширить набор свойств объекта. Получаем следующего динозавра:

struct Object
{
int IntegerProperty;
string StringProperty;
int OtherIntegerProperty;
int AnotherIntegerProperty;
string SomeMoreStringProperty;
string AnotherStringProperty;
};

Object FindObjectById(ObjectId id);

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

struct Object
{
int IntegerProperty;
string StringProperty;
int OtherIntegerProperty;
int AnotherIntegerProperty;
string SomeMoreStringProperty;
string AnotherStringProperty;
};

Object FindObjectById(ObjectId id, bool getAnotherStringProperty);

Далее выясняется, что некоторым клиентам это поле вроде как не нужно, но они могут его использовать при некоторых условиях, выполнение которых зависит от значения других свойств объекта. Как быть? Воспаленный мозг генерирует дурацкие идеи вроде передачи вероятности необходимости вычислить данное поле, супернавороченную систему кеширования и прочий бред. Наконец, махнув на все рукой, мол один раз- не пи...рас полно и других мест, где производительность и поболее просаживается, остается вариант с флагом, устанавливаемым в true при малейшей вероятности использования данного поля.
Обстоятельства меняются еще один раз. Ресурсы, затрачиваемые на получение других полей резко увеличиваются. Теперь и заполнение AnotherIntegerProperty требует колоссальных затрат. Скрепя сердце, говнокод "рефакторится" в вид:

struct Object
{
int IntegerProperty;
string StringProperty;
int OtherIntegerProperty;
int AnotherIntegerProperty;
string SomeMoreStringProperty;
string AnotherStringProperty;
};

enum
{
GET_ANOTHER_INTEGER = 1,
GET_ANOTHER_STRING = 2,
};

Object FindObjectById(ObjectId id, int flags = GET_ANOTHER_STRING | GET_ANOTHER_INTEGER);

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

Как быть? Да очень просто:

class Object
{
public:
typedef SomeSharedPtrType Ptr;
virtual ~Object() {}

virtual int IntegerProperty() const = 0;
virtual string StringProperty() const = 0;
virtual int OtherIntegerProperty() const = 0;
virtual int AnotherIntegerProperty() const = 0;
virtual string SomeMoreStringProperty() const = 0;
virtual string AnotherStringProperty() const = 0
};

Object::Ptr FindObjectById(ObjectId id);

Все! Вместо мертвых байтов в памяти, ничего не представляющих о своем будущем применении и никоим образом не управляющих своей жизнью, получаем ОБЪЕКТ. Самостоятельную сущность, имеющую свой взгляд на способ выполнения своих обязанностей (т.е. предоставление информации о свойствах в данной проблеме), лишь бы клиент был доволен и получал то, что ему нужно.

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

class DatabaseObject : public Object
{
public:
DatabaseObject(ObjectId id, Database db)
: Id(id)
, Db(db)
{
}

virtual int IntegerProperty() const
{
return 1;
}

virtual string StringProperty() const
{
return "string";
}

virtual int OtherIntegerProperty() const
{
return 2;
}

virtual int AnotherIntegerProperty() const
{
return Db.GetHeavyIntegerProperty(Id, "anotherIntegerProperty");
}

virtual string SomeMoreStringProperty() const
{
return "someMore";
}

virtual string AnotherStringProperty() const
{
return Db.GetHeavyStringProperty(Id, "anotherStringProperty");
}
private:
const ObjectId Id;
const Database Db;
};

Object::Ptr FindObjectById(ObjectId id)
{
...
return Object::Ptr(new DatabaseObject(id, Db));
}

Если же выясняется, что некоторые "тяжелые" свойства запрашиваются непозволительно много раз, легко и непринужденно вводится кеширование:

class DatabaseObject : public Object
{
public:
DatabaseObject(ObjectId id, Database db)
: Id(id)
, Db(db)
{
}

virtual int IntegerProperty() const
{
return 1;
}

virtual string StringProperty() const
{
return "string";
}

virtual int OtherIntegerProperty() const
{
return 2;
}

virtual int AnotherIntegerProperty() const
{
return Db.GetHeavyIntegerProperty(Id, "anotherIntegerProperty");
}

virtual string SomeMoreStringProperty() const
{
return "someMore";
}

virtual string AnotherStringProperty() const
{
return Db.GetHeavyStringProperty(Id, "anotherStringProperty");
}
private:
const ObjectId Id;
const Database Db;
};

class CachedObject : public Object
{
public:
explicit CachedObject(Object::Ptr obj)
: Delegate(obj)
{
}

virtual int IntegerProperty() const
{
return Delegate->IntegerProperty();
}

virtual string StringProperty() const
{
return Delegate->StringProperty();
}

virtual int OtherIntegerProperty() const
{
return Delegate->OtherIntegerProperty();
}

virtual int AnotherIntegerProperty() const
{
if (!AnotherInt.get())
{
AnotherInt.reset(new int(Delegate->AnotherIntegerProperty()));
}
return *AnotherInt;
}

virtual string SomeMoreStringProperty() const
{
return Delegate->SomeMoreStringProperty();
}

virtual string AnotherStringProperty() const
{
if (!AnotherStr.get())
{
AnotherStr.reset(new string(Delegate->AnotherStringProperty()));
}
return *AnotherStr;
}
private:
const Object::Ptr Delegate;
mutable std::auto_ptr AnotherInt;
mutable std::auto_ptr AnotherStr;
};

Object::Ptr FindObjectById(ObjectId id)
{
...
const Object::Ptr obj(new DatabaseObject(id, Db));
return Object::Ptr(new CachedObject(obj));
}

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

class DelegatedObject : public Object
{
public:
explicit DelegatedObject(Object::Ptr obj)
: Delegate(obj)
{
}

virtual int IntegerProperty() const
{
return Delegate->IntegerProperty();
}

virtual string StringProperty() const
{
return Delegate->StringProperty();
}

virtual int OtherIntegerProperty() const
{
return Delegate->OtherIntegerProperty();
}

virtual int AnotherIntegerProperty() const
{
return Delegate->AnotherIntegerProperty();
}

virtual string SomeMoreStringProperty() const
{
return Delegate->SomeMoreStringProperty();
}

virtual string AnotherStringProperty() const
{
return Delegate->AnotherStringProperty();
}
private:
const Object::Ptr Delegate;
};

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

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

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

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

Q: ну да, в старом варианте были проблемы, а в новом их нет чтоли? Ну будут новые поля заменяться новыми методами, и что?
A: а здесь должен вступить в силу другие принципы - SRP - single responsibility principle - принцип единственной обязанности и ISP - interface segregation principle, принцип разделения интерфейса. Иными словами- не надо класть все яйца в одну штанину корзину. Надо делить интерфейсы на части. По какому принципу- разговор отдельный.

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

Q: все эти виртуальные функции - сплошные тормоза. Лишние 3 такта на каждый вызов.
A: начнем с того, что современные компиляторы прекрасно оптимизируют вызовы виртуальных функций. Плюс недаром же я начал пример с практического результата- ускорение за счет невызова ненужных функций. А эта экономия будет посерьезнее "тактов на вызов". Я не говорю уже о том, что изменение имплементаций и их перетасовка в фабрике никоим образом не скажется на клиентах, что сэкономит время на перестойку программы и ее развертывание.

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

Q: меня это не касается. Я не пишу на С++. Я пишу на Java/C# etc
A: еще как касается. Это касается любого языка, поддерживающего принципы ООП (наследование-полиморфизм-инкапсуляция). Про функциональные языки говорить не могу- не силен, но в том или ином виде вышеперечисленные принципы там применить можно и нужно- они ведь не для языка, они для программирования:)

Немного позже, если будет кому интересно, расскажу о других принципах. Все, разумеется, выстрадано на личном опыте.

программирование

Previous post Next post
Up