Правила проектирования¶
Правила проектирования анализируют внутреннюю структуру ваших классов -- насколько они сфокусированы, как используется наследование и не взяли ли классы на себя слишком много ответственности. Эти правила помогают обнаружить структурные проблемы до того, как их исправление станет дорогим.
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), основанный на графах:
- Строится граф, где каждый instance-метод — это узел (статические методы исключены)
- Выявляются stateless constant-методы — методы без доступа к свойствам, без вызовов instance-методов и с телом, которое лишь возвращает константное значение — и объединяются в один виртуальный узел
- Ребро добавляется между двумя методами, если они разделяют свойство (
$this->property) или один вызывает другой ($this->method()) - 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 — оба значения указывают на низкую связность, но числа означают разное.
Настройка¶
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. Вместо множества подклассов параметризуйте поведение через зависимости конструктора.
- Переместите общую логику в трейт, если вам всё ещё нужна общая функциональность без жёсткой связи наследования.
Настройка¶
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 = 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.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,
) {}
}
Как исправить¶
- Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса.
- Превратите в DTO -- если класс намеренно является только данными, сделайте его
readonlyдля выражения намерения. - Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.
Конфигурация¶
# 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 методов, обрабатывает:
// - аутентификацию пользователей
// - управление сессиями
// - маршрутизацию запросов
// - форматирование ответов
// - обработку ошибок
// - логирование
// - кеширование
}
Как исправить¶
- Извлеките классы по ответственности -- определите кластеры методов, работающих с одними данными, и выделите их в отдельные классы.
- Применяйте принцип единственной ответственности -- каждый класс должен иметь одну причину для изменения.
- Используйте композицию -- замените иерархии наследования составными объектами.