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

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

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


LCOM -- Недостаток связности методов

Rule ID: design.lcom

Что измеряет

LCOM отвечает на вопрос: "Этот класс делает одно дело или несколько несвязанных?"

Он работает, анализируя, какие свойства (поля) использует каждый метод. Если два метода используют одно и то же свойство, они считаются связанными. LCOM считает, сколько изолированных групп связанных методов существует в классе.

  • LCOM = 1 -- все методы связаны. Класс связный и сфокусированный.
  • LCOM = 2 -- есть две группы методов, которые не разделяют ни одного свойства. Класс, возможно, выполняет две отдельные задачи.
  • LCOM = 5 -- пять несвязанных групп. Этот класс почти наверняка делает слишком много.

Представьте команду: если все участники работают над одним проектом, команда сплочённая (LCOM = 1). Если половина работает над проектом A, а другая половина -- над проектом B без пересечений, команду, вероятно, стоит разделить на две (LCOM = 2).

Как читать значение:

LCOM Интерпретация
1 Связный класс -- единая ответственность
2--3 Умеренно, возможны отдельные зоны ответственности
4--5 Низкая связность -- рассмотрите разделение
6+ Очень низкая связность -- класс делает слишком много

Пороговые значения

Значение Серьёзность Что означает
1--2 OK Связный класс, все методы работают вместе
3--4 Warning Класс может иметь несколько ответственностей
5+ Error Класс явно делает слишком много, нужно разделить

Пример

Этот класс имеет низкую связность -- две группы методов работают с несвязанными данными:

class UserManager
{
    private string $name;
    private string $email;
    private float $balance;
    private array $transactions;

    // Группа 1: работает с $name и $email
    public function getName(): string { return $this->name; }
    public function getEmail(): string { return $this->email; }
    public function updateProfile(string $name, string $email): void
    {
        $this->name = $name;
        $this->email = $email;
    }

    // Группа 2: работает с $balance и $transactions
    public function getBalance(): float { return $this->balance; }
    public function addTransaction(float $amount): void
    {
        $this->transactions[] = $amount;
        $this->balance += $amount;
    }
    public function getTransactionHistory(): array { return $this->transactions; }
}

У этого класса LCOM = 2. Методы профиля и финансовые методы используют совершенно разные свойства.

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

  • Разделите класс на несколько классов, по одному на каждую ответственность. В примере: UserProfile для имени/email, UserWallet для баланса/транзакций.
  • Ищите естественные границы. Если методы группируются вокруг разных наборов свойств, эти группы -- ваши новые классы.
  • Используйте композицию. Исходный класс может делегировать работу новым сфокусированным классам, если это необходимо.

Совет

Readonly-классы (DTO, value objects) исключены по умолчанию, потому что их свойства обычно задаются один раз в конструкторе и читаются по отдельности -- это естественно даёт высокие значения LCOM, хотя дизайн класса в порядке. Управлять этим можно через опцию excludeReadonly.

Особенности реализации

Qualimetrix использует алгоритм LCOM4 (Hitz & Montazeri, 1995), основанный на графах:

  1. Строится граф, где каждый instance-метод — это узел (статические методы исключены)
  2. Выявляются stateless constant-методы — методы без доступа к свойствам, без вызовов instance-методов и с телом, которое лишь возвращает константное значение — и объединяются в один виртуальный узел
  3. Ребро добавляется между двумя методами, если они разделяют свойство ($this->property) или один вызывает другой ($this->method())
  4. LCOM4 = количество компонентов связности в этом графе

Это наиболее общепринятый вариант LCOM в современной литературе. Значение 1 означает, что все методы взаимосвязаны — класс связный.

Отклонение от оригинальной спецификации

Оригинальный LCOM4 (Hitz & Montazeri, 1995) определяет рёбра только через общий доступ к свойствам. Qualimetrix расширяет это рёбрами по вызовам методов ($this->method()), следуя стандартному подходу современных инструментов (SonarQube, JDepend). Без этого расширения хорошо декомпозированный класс, обращающийся к свойствам через геттеры, выглядел бы как имеющий плохую связность.

Группировка stateless-методов

Методы, которые не обращаются к состоянию экземпляра и возвращают только константные значения (скаляры, self::NAME, массивы констант), объединяются в один виртуальный узел в графе LCOM. Это предотвращает раздувание значения LCOM из-за metadata-методов, навязанных интерфейсами, таких как getName() или getDescription(), каждый из которых иначе образовывал бы отдельную компоненту связности. Например, класс с двумя такими методами и одной группой, обращающейся к состоянию, получит LCOM=2 вместо LCOM=4.

Сравнение с другими инструментами

phpmetrics использует формулу Henderson-Sellers LCOM, которая даёт значения в совершенно другой шкале (0.0 до 1.0+). Эти значения несравнимы с LCOM4 в Qualimetrix. Класс с LCOM=2 в Qualimetrix может показать LCOM=0.8 в phpmetrics — оба значения указывают на низкую связность, но числа означают разное.

Настройка

# qmx.yaml
rules:
  design.lcom:
    warning: 4
    error: 6
    min_methods: 5
    exclude_readonly: true
bin/qmx check src/ --rule-opt="design.lcom:warning=4"
bin/qmx check src/ --rule-opt="design.lcom:error=6"
bin/qmx check src/ --rule-opt="design.lcom:min_methods=5"
bin/qmx check src/ --rule-opt="design.lcom:exclude_readonly=false"

NOC -- Количество дочерних классов

Rule ID: 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"

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

Rule ID: design.inheritance

Что измеряет

Это правило считает, сколько уровней родительских классов имеет класс. Эта метрика называется глубиной дерева наследования (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.inheritance:
    warning: 5
    error: 7
bin/qmx check src/ --rule-opt="design.inheritance:warning=5"
bin/qmx check src/ --rule-opt="design.inheritance:error=7"

Покрытие типами (Type Coverage)

Rule ID: design.type-coverage

Что измеряет

Проверяет процент объявлений типов в классе. Может создать до трёх нарушений на класс:

  • Покрытие типами параметров -- процент параметров методов с объявлениями типов
  • Покрытие типами возвращаемых значений -- процент методов с объявлениями типов возвращаемых значений
  • Покрытие типами свойств -- процент свойств с объявлениями типов

В отличие от большинства правил, это правило использует инвертированные пороги: меньшие значения хуже. Предупреждение выдаётся, когда покрытие падает ниже порога warning, а ошибка -- когда падает ниже порога error.

Как читать значение:

Покрытие Интерпретация
0--49% Низкое покрытие типами
50--79% Умеренное покрытие типами
80--100% Хорошее покрытие типами

Пороговые значения

Аспект Warning (ниже) Error (ниже)
Параметры 80% 50%
Возврат 80% 50%
Свойства 80% 50%

Пример

class LegacyService
{
    private $cache;       // нет типа -> снижает покрытие свойств
    public $debug = true; // нет типа -> снижает покрытие свойств

    // Нет типа возврата -> снижает покрытие возвратов
    // $data без типа -> снижает покрытие параметров
    public function process($data)
    {
        // ...
    }

    public function reset(): void
    {
        // есть тип возврата -- хорошо
    }
}
// Покрытие параметров: 0% (0 из 1 типизированы) -> Error
// Покрытие возвратов: 50% (1 из 2 типизированы) -> Warning
// Покрытие свойств: 0% (0 из 2 типизированы) -> Error

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

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

class LegacyService
{
    private CacheInterface $cache;
    public bool $debug = true;

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

    public function reset(): void { /* ... */ }
}

Совет

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

Настройка

# qmx.yaml
rules:
  design.type-coverage:
    param_warning: 80
    param_error: 50
    return_warning: 80
    return_error: 50
    property_warning: 80
    property_error: 50
bin/qmx check src/ --rule-opt="design.type-coverage:param_warning=90"
bin/qmx check src/ --rule-opt="design.type-coverage:param_error=60"

Класс данных (Data Class)

Идентификатор правила: design.data-class Серьезность: Warning

Что измеряет

Обнаруживает классы с высокой публичной поверхностью (WOC -- Weight of Class, % публичных методов), но низкой сложностью (WMC -- Weighted Methods per Class). Такие классы в основном предоставляют данные через геттеры/сеттеры без инкапсуляции значимого поведения. Основано на метриках Lanza & Marinescu.

Намеренные DTO исключаются: readonly-классы, классы только с promoted properties и классы, помеченные как data-классы, не вызывают предупреждений.

Пороговые значения

Метрика Условие По умолчанию
WOC ≥ порога 80%
WMC ≤ порога 10
Минимум методов 3

Пример

// Помечается: высокая публичная поверхность, низкая сложность, не 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. Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса.
  2. Превратите в DTO -- если класс намеренно является только данными, сделайте его readonly для выражения намерения.
  3. Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.

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

# qmx.yaml
rules:
  design.data-class:
    woc_threshold: 80
    wmc_threshold: 10
    min_methods: 3
    exclude_readonly: true
    exclude_promoted_only: 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 методов, обрабатывает:
    // - аутентификацию пользователей
    // - управление сессиями
    // - маршрутизацию запросов
    // - форматирование ответов
    // - обработку ошибок
    // - логирование
    // - кеширование
}

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

  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