Автор фото: Stable Diffusion, Deliberate v2.0 (8-bit)
Избегайте отрицательных условий
Разберём на примерах известное правило избегания отрицаний в условиях. Какие бывают отрицания и чем они опасны.
Начнём с первоисточника
Как у Дяди Боба
G29: Избегайте отрицательных условий Отрицательные условия немного сложнее для понимания, чем положительные. Таким образом, по возможности старайтесь формулировать положительные условия. Например запись
if (buffer.shouldCompact())предпочтительнее записи
if (!buffer.shouldNotCompact())
Заметим, что в такой формулировке и с таким примером не совсем ясно чего стоит избегать - логических операторов отрицания или отрицаний при именовании переменных и методов. Однако, кажется, можно сказать что стоит избегать двойного отрицания, как очевидно сложного для восприятия условия.
Что ж, давайте разбираться.
Операторы отрицания
Рассмотрим синтетический пример метода проверки авторизации пользователя и соответствующий вариант его использования:
class AnonymousAuthenticator:
def is_authenticated(self, request: Request) -> bool:
return True
class PublicController:
def index_action(self, request: Request) -> str:
authenticator = AnonymousAuthenticator()
if not authenticator.is_authenticated(request):
return "authentication required"
# ...
# do some business logic
# ...
return "Successfuly authenticated"
Классический ход - если пользователь не авторизован, то сразу же вернём ответ. Если предположить что Роберт Мартин имел ввиду избегать операторы отрицания, то какие у нас есть варианты?
- Переписать условие так, чтобы не было оператора отрицания. В таком случае основная бизнес логика ляжет внутрь
if is_authenticated, а негативный кейс уедет в самый конец, возможно, на пару экранов, где уже будет неясно какое условие там проверялось. Кроме того, весь код получит лишний уровень вложенности, что как известно негативно влияет на его восприятие. - Добавить метод
is_not_authenticatedв классAnonymousAuthenticator. В таком случае мы либо получим дублирование логики с методомis_authenticated, которое приведёт к ошибкам в рассинхронизации двух вариантов реализации, либо отрицательное условие с вызовомis_authenticatedпереместится внутрь этого метода. Да ещё и отрицание теперь будет в названии метода. - Добавить метод
is_forbiddenв классAnonymousAuthenticator. Проблемы те же что для предыдущего варианта, но имя уже как будто не содержит отрицания.
Все три варианта выглядят сомнительно и имеют свои недостатки, однако последний содержит нюанс, который я рекомендую избегать.
Отрицания в языке
Если бы в естественных языках отрицание выражалось только частицами “не” и “нет”, то это были бы очень скудные по выразительности языки.
Рассмотрим каким образом отрицания могут реализовываться в языках программаривания:
not- отрицание с помощью частицnoиnotdisable,inactive- отрицание с помощью префиксовin-,un-,dis-powerOff- отрицание с помощью фразовых глаголов (комбинации глагола и предлога, образующие цельную смысловую единицу)forbid,reject- слова с очевидным или неочевидным отрицанием в их смысловой нагрузке, но формально не попадающие под предыдущие категории
Как ни странно, самый безобидный из этих вариантов - именно первый. Рассмотрим пример:
class EditorAccessCheck
{
const ENABLED = 'enabled';
public function isNotAllowed(User $user): bool
{
return !$user->isEditor();
}
}
class NewsController
{
public function editAction(Request $request, EditorAccessCheck $accessCheck): Response
{
if ($accessCheck->isNotAllowed()) {
return AccessDeniedResponse();
}
return Response("Welcome to editor's club!");
}
}
Использованием метода читается довольно просто и понятно, несмотря на отриание в названии. Заметим что условие, которое проверяется внутри самого метода также содержит отрицание через оператор !, однако и оно не мешает воспринимать код. Проблемы начнутся когда кто-нибудь решит воспользоваться EditorAccessCheck в ином контексте:
class EditorActionSubscriber extends EventSubscriber
{
public function __construct(
private EditorAccessCheck $accessCheck
) {}
public function supports(): bool
{
return !$this->accessCheck->isNotAllowed();
}
public function onEvent(UserActionEvent $event): void
{
// React to $event
}
}
Здесь появляется проблема: двойное отрицание. Как избавиться от двойного отрицания в условиях? Изменить условие так, чтобы можно было убрать одно из них. Вот только убрать таким образом можно лишь оператор отрицания, потому что для изменения названия метода придётся пройтись по всей кодовой базе и не просто изменить название вызываемого метода, но и изменить всю логику этих вызовов, чтобы сохранить работоспособность приложения.
Неявное двойное отрицание
Все довольно просто когда мы смотрим на переменные с частицами No и Not, однако то же самое касается и вариантов с неявным отрицанием:
if (!$disabled) {
call_when_enabled();
}
или
if not inactive:
call_when_active()
Оба варианта содержат двойное отрицание, которое сложно воспринимать, даже несмотря на то, что ни одна из переменных не имеет явного отрицания с частицами not.
Вывод
Сам по себе оператор отрицания не несёт в себе ничего плохого и не может рассматриваться как запах кода. Проблемы с пониманием кода возникают при двойном отрицании, которое можно легко исправить если отрицание не заложено — явно или неявно — в имена переменных и методов.
Some shit
Рассмотрим примеры из реальных проектов. Например, отрицание проверки отрицательного флага
if (!$widget->isFlagEnabled(ProductFlag::DISABLED)) {
// do something
}
Чтобы каждый раз не пытаться понять что имелось ввиду, можно инкапсулировать эту логику в метод с понятным названием:
public function isActive(): bool
{
return !$this->isFlagEnabled(ProductFlag::DISABLED);
}
Хотя кажется правильней было бы ориентироваться на константу ProductFlag::ENABLED
Ещё один пример в коде, который пока нельзя назвать проблемой, но уже явно попахивает:
abstract class WidgetFlag
{
public const DISABLED = 0x0001;
public static function getList(): array
{
return [
self::DISABLED => 'Выключено',
];
}
}
Что мы видим:
- Константа с неявным отрицательным смыслом;
- Из значения
0x0001можно сделать предположение, что есть, вероятно среди возможных значений хранящихся в БД или приходящих по API, ещё неявная положительная константаACTIVE = 0x0000;
Иногда плохие примеры получаются из-за проблем самого языка. Например в некоторых языках различные типы могут неявно приводиться к булеву типу, причем с различными побочными эффектами. И бывает так что для борьбы с этим выбирают не самые удачные решения:
function isAdmin(user) {
return ('admin' in user) ? !!user.admin : false
}
Прилагательное вместо глагола inactive -> deactivate / setInactive:
public function inactive(): void
{
$this->isActive = false;
}
Вместо заключения
<input type="checkbox" disabled="disabled" />
Мы вероятно уже привыкли и это уже не исправить, но это отвратительно: двойное отрицание которое всё же отрицание