Перейти к содержанию

Правила проектирования

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


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. Вместо множества подклассов параметризуйте поведение через зависимости конструктора.
  • Переместите общую логику в трейт, если вам всё ещё нужна общая функциональность без жёсткой связи наследования.

Настройка

# qmx.yaml
rules:
  design.noc:
    warning: 12
    error: 20
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):

rules:
  design.noc:
    threshold: 12   # warning=12, error=12 → все нарушения становятся ошибками
bin/qmx check src/ --rule-opt="design.noc:threshold=12"

Глубина наследования

Rule ID: design.dit

Судимая метрика: design.dit

Что измеряет

Это правило считает, сколько уровней родительских классов имеет класс. Эта метрика называется глубиной дерева наследования (DIT -- Depth of Inheritance Tree).

  • class A {} -- DIT = 0 (нет родителя)
  • class B extends A {} -- DIT = 1
  • class C extends B {} -- DIT = 2
  • class 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 классов в цепочке.

Как исправить

  • Предпочитайте композицию наследованию. Вместо цепочки базовых классов внедряйте поведение через зависимости:

    class UserEntity
    {
        public function __construct(
            private Timestamps $timestamps,
            private SoftDelete $softDelete,
            private AuditLog $auditLog,
        ) {}
    }
    
  • Используйте интерфейсы + трейты для общего поведения, не требующего глубоких иерархий:

    class UserEntity implements Timestamped, SoftDeletable
    {
        use TimestampsTrait;
        use SoftDeleteTrait;
    }
    
  • Уплощайте иерархию. Спросите себя, действительно ли каждый промежуточный класс необходим, или его можно объединить с родителем или потомком.

Примечание

Базовые классы фреймворков (например, сущности Doctrine или контроллеры Symfony) учитываются в DIT. Если ваш фреймворк навязывает 2--3 уровня наследования, скорректируйте пороговые значения соответственно.

Настройка

# qmx.yaml
rules:
  design.dit:
    warning: 5
    error: 7
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):

rules:
  design.dit:
    threshold: 5   # warning=5, error=5 → все нарушения становятся ошибками
bin/qmx check src/ --rule-opt="design.dit:threshold=5"

Покрытие типами параметров (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

Как исправить

Добавьте объявления типов параметрам:

public function process(array $data)
{
    // ...
}

Tip

Начните с типизации нового кода, а существующий типизируйте по ходу рефакторинга. PHP 8.0+ поддерживает union-типы (string|int), PHP 8.1+ -- intersection-типы (Countable&Iterator) для неудобных случаев.

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.param:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.param:threshold=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, когда он всегда бросает исключение или завершает процесс.

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.return:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.return:threshold=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:

public function __construct(private readonly CacheInterface $cache) {}

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.property:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.property:threshold=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,
    ) {}
}

Как исправить

  1. Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса. Замена пары геттер/сеттер на метод, выражающий саму операцию, напрямую поднимает WOC.
  2. Превратите в DTO -- если класс намеренно является только данными, сделайте его readonly для выражения намерения.
  3. Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.

Конфигурация

# 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   # по умолчанию: классы исключений никогда не помечаются
bin/qmx check src/ --rule-opt="design.data-class:exclude_exceptions=false"

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 методов, обрабатывает:
    // - аутентификацию пользователей
    // - управление сессиями
    // - маршрутизацию запросов
    // - форматирование ответов
    // - обработку ошибок
    // - логирование
    // - кеширование
}

Как исправить

  1. Извлеките классы по ответственности -- определите кластеры методов, работающих с одними данными, и выделите их в отдельные классы.
  2. Применяйте принцип единственной ответственности -- каждый класс должен иметь одну причину для изменения.
  3. Используйте композицию -- замените иерархии наследования составными объектами.

Конфигурация

# qmx.yaml
rules:
  design.god-class:
    wmc_threshold: 47
    lcom_threshold: 3
    tcc_threshold: 0.33
    class_loc_threshold: 300
    min_criteria: 3
    min_methods: 3   # по умолчанию: классы с меньшим числом методов никогда не помечаются
    exclude_readonly: true
bin/qmx check src/ --rule-opt="design.god-class:min_methods=5"