Правила проектирования¶
Правила проектирования анализируют внутреннюю структуру ваших классов -- насколько они сфокусированы, как используется наследование и не взяли ли классы на себя слишком много ответственности. Эти правила помогают обнаружить структурные проблемы до того, как их исправление станет дорогим.
NOC -- Количество дочерних классов¶
Rule ID: design.noc
Судимая метрика: design.noc
Что измеряет¶
NOC считает, сколько классов напрямую наследуют (расширяют) данный класс.
Например, если 12 классов пишут extends BaseRepository, то у BaseRepository NOC = 12.
Как читать значение:
| NOC | Интерпретация |
|---|---|
| 0 | Листовой класс (нет наследников) |
| 1--5 | Нормальное наследование |
| 6--10 | Много наследников -- проверьте базовый класс |
| 10+ | Тяжёлый базовый класс -- рассмотрите композицию |
Почему это важно¶
Класс с большим количеством потомков -- это точка высокого воздействия изменений. Любая модификация родительского класса -- изменение сигнатуры метода, изменение поведения, добавление абстрактных методов -- затрагивает каждый дочерний класс. Чем больше потомков, тем рискованнее любое изменение.
Высокий NOC также может указывать на:
- Чрезмерную зависимость от наследования вместо композиции
- Потенциальное нарушение принципа подстановки Лисков -- все ли потомки действительно ведут себя как родитель?
- Трудности рефакторинга -- изменение базового класса требует обновления всех подклассов
Пороговые значения¶
| Значение | Серьёзность | Что означает |
|---|---|---|
| 0--9 | OK | Управляемое количество подклассов |
| 10--14 | Warning | Много потомков, изменения будут иметь широкое влияние |
| 15+ | Error | Слишком много потомков, рассмотрите использование интерфейсов |
Пример¶
abstract class BaseHandler
{
abstract public function handle(Request $request): Response;
protected function validate(Request $request): void { /* ... */ }
protected function authorize(Request $request): void { /* ... */ }
}
// 15 обработчиков наследуют BaseHandler -- NOC = 15 -> ERROR
class CreateUserHandler extends BaseHandler { /* ... */ }
class UpdateUserHandler extends BaseHandler { /* ... */ }
class DeleteUserHandler extends BaseHandler { /* ... */ }
class ListUsersHandler extends BaseHandler { /* ... */ }
class CreateOrderHandler extends BaseHandler { /* ... */ }
// ... ещё 10 обработчиков
Как исправить¶
- Используйте интерфейс вместо базового класса. Каждый класс реализует интерфейс независимо, поэтому изменение одного не влияет на остальные.
- Используйте паттерн Strategy. Вместо множества подклассов параметризуйте поведение через зависимости конструктора.
- Переместите общую логику в трейт, если вам всё ещё нужна общая функциональность без жёсткой связи наследования.
Настройка¶
bin/qmx check src/ --rule-opt="design.noc:warning=12"
bin/qmx check src/ --rule-opt="design.noc:error=20"
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
Глубина наследования¶
Rule ID: design.dit
Судимая метрика: design.dit
Что измеряет¶
Это правило считает, сколько уровней родительских классов имеет класс. Эта метрика называется глубиной дерева наследования (DIT -- Depth of Inheritance Tree).
class A {}-- DIT = 0 (нет родителя)class B extends A {}-- DIT = 1class C extends B {}-- DIT = 2class D extends C {}-- DIT = 3
Как читать значение:
| DIT | Интерпретация |
|---|---|
| 0 | Корневой класс (нет родителя) |
| 1--3 | Нормальная глубина |
| 4--6 | Глубокая иерархия -- может быть хрупкой |
| 6+ | Очень глубокая -- хрупкая, трудно понять |
Почему это важно¶
Когда вы читаете класс, расположенный глубоко в дереве наследования, вам нужно понять все его родительские классы, чтобы разобраться в его поведении. Каждый уровень добавляет неявное поведение: унаследованные методы, переопределённые методы, разделяемое состояние, побочные эффекты конструкторов.
Класс с DIT = 6 означает, что вам потенциально нужно прочитать 7 классов, чтобы понять его полное поведение. Это трудно, подвержено ошибкам и делает код устойчивым к изменениям.
Пороговые значения¶
| DIT | Серьёзность | Что означает |
|---|---|---|
| 0--3 | OK | Приемлемая глубина наследования |
| 4--5 | Warning | Становится глубоко, проверьте необходимость наследования |
| 6+ | Error | Слишком глубоко, вероятно проблема дизайна |
Пример¶
class BaseEntity {} // DIT = 0
class TimestampedEntity extends BaseEntity {} // DIT = 1
class SoftDeletableEntity extends TimestampedEntity {} // DIT = 2
class AuditableEntity extends SoftDeletableEntity {} // DIT = 3
class VersionedEntity extends AuditableEntity {} // DIT = 4 -> Warning
class TenantEntity extends VersionedEntity {} // DIT = 5 -> Warning
class UserEntity extends TenantEntity {} // DIT = 6 -> Error!
Чтобы понять UserEntity, нужно прочитать все 7 классов в цепочке.
Как исправить¶
-
Предпочитайте композицию наследованию. Вместо цепочки базовых классов внедряйте поведение через зависимости:
-
Используйте интерфейсы + трейты для общего поведения, не требующего глубоких иерархий:
-
Уплощайте иерархию. Спросите себя, действительно ли каждый промежуточный класс необходим, или его можно объединить с родителем или потомком.
Примечание
Базовые классы фреймворков (например, сущности Doctrine или контроллеры Symfony) учитываются в DIT. Если ваш фреймворк навязывает 2--3 уровня наследования, скорректируйте пороговые значения соответственно.
Настройка¶
bin/qmx check src/ --rule-opt="design.dit:warning=5"
bin/qmx check src/ --rule-opt="design.dit:error=7"
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
Покрытие типами параметров (Parameter Type Coverage)¶
Rule ID: design.type-coverage.param
Судимая метрика: design.type-coverage.param
Что измеряет¶
Процент параметров методов и функций класса, у которых объявлен тип.
Как и два правила ниже, это правило использует инвертированные пороги: меньшие значения хуже. Предупреждение выдаётся, когда покрытие падает ниже порога warning, а ошибка -- когда падает ниже порога error. Класс без параметров типизировать нечего, и он не сообщается никогда.
Как читать значение:
| Покрытие | Интерпретация |
|---|---|
| 0--49% | Низкое покрытие типами |
| 50--79% | Умеренное покрытие типами |
| 80--100% | Хорошее покрытие типами |
Три правила, а не одно
Параметры, возвращаемые значения и свойства раньше были тремя каналами одного правила design.type-coverage с одним набором опций. Теперь это три правила, у каждого свой порог, своё подавление и своя запись в бейзлайне: кодовая база обычно типизирует их с разной скоростью -- см. примечание о миграции.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
// у $data нет типа -> снижает покрытие параметров
public function process($data)
{
// ...
}
public function reset(int $attempts): void
{
// типизировано -- хорошо
}
}
// Покрытие параметров: 50% (1 из 2) -> Warning
Как исправить¶
Добавьте объявления типов параметрам:
Tip
Начните с типизации нового кода, а существующий типизируйте по ходу рефакторинга. PHP 8.0+ поддерживает union-типы (string|int), PHP 8.1+ -- intersection-типы (Countable&Iterator) для неудобных случаев.
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.param:warning=90"
bin/qmx check src/ --param-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.param:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Покрытие типами возвращаемых значений (Return Type Coverage)¶
Rule ID: design.type-coverage.return
Судимая метрика: design.type-coverage.return
Что измеряет¶
Процент методов и функций класса, у которых объявлен тип возвращаемого значения. Пороги инвертированные, ровно как у покрытия типами параметров: меньше -- хуже.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
// нет типа возврата -> снижает покрытие возвращаемых значений
public function process(array $data)
{
// ...
}
public function reset(): void
{
// тип возврата есть -- хорошо
}
}
// Покрытие возвращаемых значений: 50% (1 из 2) -> Warning
Как исправить¶
Объявите, что метод возвращает; используйте void, когда он не возвращает ничего, и never, когда он всегда бросает исключение или завершает процесс.
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.return:warning=90"
bin/qmx check src/ --return-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.return:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Покрытие типами свойств (Property Type Coverage)¶
Rule ID: design.type-coverage.property
Судимая метрика: design.type-coverage.property
Что измеряет¶
Процент объявленных свойств класса, у которых есть тип. Пороги инвертированные, ровно как у покрытия типами параметров: меньше -- хуже.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
private $cache; // нет типа -> снижает покрытие свойств
public bool $debug = true; // типизировано -- хорошо
}
// Покрытие свойств: 50% (1 из 2) -> Warning
Как исправить¶
Типизируйте свойства, а те, что присваивает конструктор, объявляйте через promotion:
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.property:warning=90"
bin/qmx check src/ --property-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.property:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Класс данных (Data Class)¶
Идентификатор правила: design.data-class
Судимая метрика: design.woc
Серьезность: Warning
Что измеряет¶
Обнаруживает классы, публичный интерфейс которых в основном отдаёт данные, а не несёт поведение. WOC (Weight of Class, Lanza & Marinescu) -- это доля публичного интерфейса, несущая поведение: функциональные публичные методы, делённые на все публичные члены -- публичные методы, включая аксессоры, плюс публичные свойства. Data-класс сочетает низкий WOC с низким WMC (Weighted Methods per Class): он выставляет состояние наружу и мало что с ним делает.
Намеренные DTO исключаются: readonly-классы и классы только с promoted properties не помечаются, как и интерфейсы, абстрактные классы, классы исключений и классы без свойств. Трейты не исключаются: трейт с полями и их аксессорами это data-класс, размазанный по единице переиспользования.
Как классифицируется метод
Принадлежность к аксессорам определяется по имени, а не по телу: get*, is*, has* и set* (а также голые get/is/has/set) считаются доступом к данным, всё остальное -- поведением. Тело метода не читается, поэтому публичный метод, который лишь передаёт вызов соседу -- enterNode() визитора, dispatch() таблицы маршрутизации -- это поведение. WOC описывает форму публичного интерфейса, а не вес работы за ним. Конструктор не входит ни в числитель, ни в знаменатель: у Lanza & Marinescu функциональный метод это метод, не являющийся ни аксессором, ни конструктором. Класс вообще без публичных членов получает 100 и никогда не помечается. Считаются только члены, объявленные самим классом, — унаследованные и подмешанные трейтом невидимы.
Пороговые значения¶
| Метрика | Условие | По умолчанию |
|---|---|---|
| WOC | ≤ порога | 33% |
| WMC | ≤ порога | 10 |
| Минимум членов | ≥ | 3 |
Граница включающая: ровно 33% это срабатывание. Обе метрические оси -- верхние границы, поэтому в @qmx-threshold design.data-class W E
граница WOC может быть ниже границы WMC, и это не ошибка порядка.
Минимум членов считает все объявленные методы (включая аксессоры) плюс все объявленные свойства: структура из публичных полей вообще не объявляет методов и всё равно должна попадать в область правила.
Пример¶
// Помечается: весь публичный интерфейс -- доступ к данным, не readonly
class UserProfile
{
private string $name;
private string $email;
private string $phone;
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getPhone(): string { return $this->phone; }
public function setPhone(string $phone): void { $this->phone = $phone; }
}
// Не помечается: намеренный DTO (readonly)
readonly class UserDTO
{
public function __construct(
public string $name,
public string $email,
) {}
}
Как исправить¶
- Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса. Замена пары геттер/сеттер на метод, выражающий саму операцию, напрямую поднимает WOC.
- Превратите в DTO -- если класс намеренно является только данными, сделайте его
readonlyдля выражения намерения. - Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.
Конфигурация¶
# qmx.yaml
rules:
design.data-class:
woc_threshold: 33
wmc_threshold: 10
min_members: 3
exclude_readonly: true
exclude_promoted_only: true
exclude_exceptions: true # по умолчанию: классы исключений никогда не помечаются
God Class (Божественный класс)¶
Идентификатор правила: design.god-class
Серьезность: Warning (3+ критерия) / Error (все оцениваемые критерии)
Что измеряет¶
Обнаруживает God-классы -- чрезмерно сложные, крупные классы с низкой связностью. Использует мульти-критериальный подход Lanza & Marinescu: класс помечается, когда он соответствует минимум minCriteria из до 4 оцениваемых критериев.
Критерии (всего 4):
| Критерий | Условие | По умолчанию | Описание |
|---|---|---|---|
| WMC | ≥ порога | 47 | Взвешенные методы класса |
| LCOM4 | ≥ порога | 3 | Недостаток связности |
| TCC | < порога | 0.33 | Тесная связность класса (инвертировано) |
| Class LOC | ≥ порога | 300 | Физические строки кода |
Недостающие метрики уменьшают количество оцениваемых критериев. Если оцениваемых критериев меньше minCriteria, нарушение не создаётся.
Пример¶
// Помечается: высокий WMC, высокий LCOM, низкий TCC, большой размер
class ApplicationManager
{
// 400+ LOC, 25 методов, обрабатывает:
// - аутентификацию пользователей
// - управление сессиями
// - маршрутизацию запросов
// - форматирование ответов
// - обработку ошибок
// - логирование
// - кеширование
}
Как исправить¶
- Извлеките классы по ответственности -- определите кластеры методов, работающих с одними данными, и выделите их в отдельные классы.
- Применяйте принцип единственной ответственности -- каждый класс должен иметь одну причину для изменения.
- Используйте композицию -- замените иерархии наследования составными объектами.