Класс - это набор данных и методов, имеющих общую, целостную, хорошо определенную сферу ответственности. Данные - необязательный элемент, в классе могут быть только методы. Основной целью создания классов является разделение программы на как можно более сильно изолированные друг от друга части, чтобы при работе над одной из них можно было не думать о других частях программы. Качественные классы можно назвать абстрактными типами данных, без понимания этой концепции программисты создают классы, в которых от изначальной идеи остается только название - просто контейнеры с набором плохо связанных между собой данных и методов. Суть в том, что сложные объекты рассматриваются как новый тип данных. К числам и строкам добавляются графические окна, таблицы, шрифты, люди, файлы. Абстрактные типы данных позволяют скрыть реальную сложность программы и сделать код более понятным и простым в поддержке.
Разумные причины для создания классов:
- Снижение сложности. Это самая важная причина создания классов. Создайте класс и скройте внутри него сложность, чтобы о ней не пришлось думать при работе над другими частями программ.
- Изоляция сложности. Любые ошибки гораздо проще найти и исправить, если они скрыты внутри одного класса, а не разбросаны по всей программе.
- Ограничение влияния изменений. Гораздо проще изменить один класс, нежели исправлять разные части программы.
- Моделирование объектов реального мира. Использующий термины реального мира код высокого уровня гораздо проще для понимания, нежели операции над сотнями и тысячами низкоуровневых элементов.
- Моделирование абстрактных объектов. Классический пример - классы Shape и частные случаи в виде классов Square и Circle
- Скрытие деталей реализации
- Скрытие глобальных данных. Управляющий доступом к глобальным данным класс может контролировать их применение и изменение, позволяя избежать целого ряда ошибок.
- Упрощение передачи параметров в методы. Если один и тот же параметр передается в несколько методов, то вполне возможно эти методы стоит обьединить в класс, чтобы они могли использовать параметр как данные объекта
- Создание центральных точек управления, например соединениями с БД, файлами, принтерами и так далее
- Облегчение повторного использования кода. В Лаборатории проектирования ПО NASA были изучены десять проектов, активно использовавших повторное использование кода. При разработке первых проектов повторное использование было невелико, так как еще не была создана база кода. Последующие проекты смогли повторно использовать 35% кода в случае процедурного подхода и 70% в случае объектно-ориентированного подхода. Если благодаря заблаговременному планированию можно не писать 70% кода, то это очень хорошо. Методика NASA исключала изначальное создание классов с расчетом на повторное использование. Вместо этого в начале разработки каждого проект выделялся особый этап, входе которого анализировался код предыдущего и оптимизировался для повторного использования. Это позволяло избежать написания ненужной функциональности в расчете на "когда-нибудь потом пригодится".
- Планирование создания семейства программ. Области предполагаемых изменений для каждой программы можно изолировать в отдельных классах, после чего написание новой программы сведется к переписыванию этих классов.
- Упаковка родственных операций. Даже если у методов нет общих данных, их можно объединить в один класс по смысловому признаку (например тригонометрические функции или функции для работы со строками). Для этой цели так же можно использовать пакеты и пространства имен.
- Выполнение особых видов рефакторинга в соответствии с вышеописанными принципами.
Неправильные классы, которые НЕ надо создавать:
- Божественные классы, которые все знают и могут. Их надо разделять на более специализированные. Если класс только и делает, что извлекает данные из других классов и производит над ними операции, то стоит перенести его методы в классы с данными.
- Если в классе есть только данные, то возможно эти данные можно сделать членами других классов
- Если класс выполняет определенное действие и не имеет данных, то возможно его стоит сделать методом другого класса. Имена классов не должны напоминать глаголы.
Качественный интерфейс класса должен предоставлять согласованную абстракцию, его методы должны быть согласованны между собой.
- В идеале каждый класс должен быть реализацией только одного абстрактного типа данных. Если он реализует два и более надо разделить его на несколько классов. Если половина методов работает только с частью данных, а другая половина с другой частью - то на самом деле это два класса под маской одного.
- Необходимо четко понимать, какую именно абстракцию реализует класс. Макконелл описывают случай из практики, когда его команде было доступно два элемента управления для вывода табличных данных - простая сетка с 15 методами и сложная "таблица" с примерно 150 методами. Их во всем устраивала "сетка", не хватало только возможности раскрашивать ячейки в разные цвета. Одному программисту поручили написать оболочку для "таблицы" чтобы свести ее к "сетке" с возможностью раскрашивать ячейки. Программист поворчал насчет бессмысленной работы но через пару дней написал оболочку, честно представлявшую все 150 методов "таблицы". Это программист проделал массу бессмысленной работы, так как им нужна была реализация только 15 методов "сетки" и еще одного, раскрашивающего ячейки, которая скрывала бы всю сложность таблицы - все остальные методы были совершенно не нужны, более того они требовали дальнейших затрат времени на поддержку при изменении класса.
- По мере возможности интерфейс надо делать программным, а не семантическим. Семантическая часть - это неявные предположения о использовании интерфейса, которые не может проверить компилятор. Например: "Метод А должен быть вызван перед методом Б", "Метод А вызовет ошибку если переданный в него Элемент Данных 1 не будет инициализирован". Такие вещи можно документировать в комментариях, но все-таки лучше их избегать.
Наследование и включение
Включение подразумевает использование других классов как членов данного класса и является основным инструментом объектно-ориентированного программирования. Ему уделяют гораздо меньше внимания чем наследованию просто потому, что наследование порождает гораздо больше проблем и ошибок.
С помощью включения стоит реализовывать отношение содержит - например человек содержит имя, фамилию, а семья или список клиентов/сотрудников содержит уже людей.
Наследование подразумевает, что один класс является более специализированной версией другого класса. Основная проблема наследования в том, что оно часто противоречит главной задаче программирования - снижению сложности. Длинные и сложные цепочки наследования резко повышают сложность программы, так как многие классы оказываются тесно связаны между собой, в том числе в особенностях внутренней реализации и при изменении базовых классов надо помнить о возможном влиянии правок на все подклассы.
В связи с этим наследование является достаточно опасным инструментом, пользоваться которым нужно с осторожностью. Множественное же наследование (от нескольких классов) настолько опасно, что во многих языках вроде Java или C# его просто запретили. Макконелл сравнивает наследование с бензопилой, с ее помощью можно очень просто решать некоторые задачи, но так же просто отпилить себе ногу при малейшей неосторожности.
Общее правило наследования - с его помощью можно реализовывать только отношение "является", то есть дочерний класс должен полностью реализовывать интерфейс базового класса, не меняя его, а только добавляя некие дополнительные возможности. Клиенты должны иметь возможность использовать подклассы через интерфейс базового класса не замечая никакой разницы. Этот принцип впервые был сформулирован Барбарой Лисков и в ее назван принципом подстановки Лисков.
Следует избегать длинных и сложных иерархий наследования. Макконнелл рекомендует не делать более 2-3 уровней наследования и более 7-9 подклассов базового класса в целом. С другой стороны следует избегать базовых классов, у которых есть только один подкласс - очень часто это является признаком бессмысленного проектирования "на потом".
Так же стоит с подозрением относиться к классам, которые переопределяют метод, делая его пустым. Например если есть базовый класс Cat, уже после создания которого выяснилось, что некоторые коты не могут царапаться и был создан подкласс ScratchlessCat. Но что делать, если вы найдете кота без хвоста? Кота, который не пьет молоко? Кота, который не ловит мышей? В итоге можно получить гигантскую иерархию с классами вроде ScratchlessTailessMicelessMilklessCat. На самом деле в данном случае вместо наследование надо использовать включение, добавив в базовый класс Cat класс Claws в качестве одного из членов.
Контрольные вопросы по проектированию отдельных классов
Абстракция
- Обдумали ли вы класс как абстрактные типы данных?
- Имеет ли класс главную цель?
- Удачное ли имя присвоено классу? Описывает ли оно главную цель?
- Формирует ли интерфейс класса согласованную абстракцию?
- Явно ли интерфейс класса описывает его использование?
- Можно ли рассматривать класс как черные ящик? Достаточно ли абстрактен его интерфейс, чтобы при использовании не надо было думать о его реализации?
- Достаточно ли полон его интерфейс, чтобы другие классы не нуждались в доступе к внутренним данным?
- Исключена ли из класса информация, не связанная с его целью и смыслом?
- Обдумали ли вы разделение класса а классы-компоненты? Разделен ли он на максимально возможное количество компонентов?
- Сохраняется ли целостность интерфейса при изменении класса?
Инкапсуляция
- Сделаны ли члены класса минимально доступными?
- Избегает ли класс предоставления доступа к своим данным-членам?
- Скрывает ли класс детали реализации от других классов в максимально возможной степени, допускаемой языком программирования?
- Избегает ли класс предположений о своих клиентах, в том числе о производных классах?
- Независим ли класс от других классов, слабо ли он связан?
Наследование
- Используется ли наследование только для моделирования отношения "является"?
- Описана ли в документации класса стратегия наследования?
- Избегают ли производные классы "переопределения" непереопределяемых методов?
- Помещены ли общие интерфейсы, данные и формы поведения как можно ближе к корню дерева наследования?
- Не слишком ли много уровней включают иерархии наследования?
- Все ли данные - члены базового класса сделаны закрытыми, а не защищенными?
Другие вопросы реализации
- Класс содержит около семи данных-членов или меньше?
- Минимально ли число встречающихся в классе непосредственных и опосредованных вызовов методов другого класса?
- Сведено ли к минимуму сотрудничество класса с другими классами?
- Все ли данные-члены инициализируются в конструкторе?