Pull to refresh

Антипаттерн Primitive obsession: практические способы устранения

Level of difficultyMedium
Reading time6 min
Views5.4K

Признавайтесь, допускали такие ошибки?

func processOrder(userID int, itemID int) {	
    ...
}

processOrder(itemID, userID) // перепутан порядок аргументов

Это потому, что вы одержимы элементарными типами)

В статье обсудим антипаттерн Primitive obsession, разберём на примерах способы его устранения в разных языках программирования.

Что такое Primitive obsession?

Этот антипаттерн возникает, когда в одной области видимости используется множество переменных одного типа, но логически они не предназначены для операций друг с другом. Например, в одном классе могут хранится id пользователя, его рейтинг, количество друзей и id города в котором он живёт. Все эти переменные имеют тип int. При этом никакой логики присваивания их друг другу, сравнения или математических операций к этим переменным применить нельзя. Но язык этого не запрещает. Если случайно перепутать переменные, найти баг может быть не просто. Мы рассмотрим 4 техники:

  • Перечисления (enum)

  • Замена примитива объектом

  • Объявление новых типов

  • Обязательные именованные аргументы

Первые 3 основаны на идее усиления типизации. Четвертая техника помогает избежать ошибок, связанных с неправильным порядком аргументов при вызове функции. И, в какой-то степени, частично решает проблему Primitive obsession.

Перечисления (enum)

Рассмотрим пример на Kotlin. Есть у нас студент Вася.

class Student(  
    var name: String,  
    var grade: Int = 0  
) {  
    fun printGrade() { ... }  
}

И он написал контрольную на 85 баллов. Выставляем оценку и напечатаем его.

val s = Student("Вася");  
s.grade = 85  
s.printGrade()

Напечаталось студент: Вася, оценка: 85/5

Потому что метод печати выглядит так:

fun printGrade() {  
    println("студент: ${name}, оценка: ${grade}/5")  
}

Очевидно, он ожидает оценку по 5-бальной шкале. Но не читая кода внутри метода класса это не понятно. Поле grade принимает любое значение типа Int.

Решение. Создадим enum для оценок, обезопасив тем самым класс от неправильных значений grade с помощью типизации

enum class Grade(val value: Int) {  
    FIVE(5),  
    FOUR(4),  
    THREE(3),  
    TWO(2),  
    NOT_GRADED(0)  
}  
  
class Student2(  
    var name: String,  
    var grade: Grade = Grade.NOT_GRADED  
) {  
    fun printGrade() {  
        println("Студент: $name, оценка: ${grade.value}/5")  
    }  
}

Теперь присвоить число не получится. Можно присваивать только тип Grade.

val s = Student2("Вася");  
// s.grade = 85 // Ошибка Kotlin: The integer literal does not conform to the expected type Grade  
s.grade = Grade.FOUR  
s.printGrade() // Вывод: "Студент: Вася, оценка: 4/5"

Замена примитива объектом

Здесь у нас обычный обработчик запроса регистрации пользователя на Java. Достаём из запроса email и никнейм, валидируем, и сохраняем в бд.

public Response registerUser(RegistrationRequest request) {  
    String email = request.getEmail();  
    String nickname = request.getNickname();  
  
    if (!isValidEmail(email)) {  
        return Response.status(HttpStatus.INTERNAL_SERVER_ERROR)
          .body("Invalid email");  
    }  
  
    if (!isValidNickname(email)) {  
        return Response.status(HttpStatus.INTERNAL_SERVER_ERROR)
          .body("Invalid nickname");  
    }  
  
    User user = new User(email, nickname);  
    userRepository.save(user);  
  
    return Response.ok("User registered successfully");  
}  
  
private boolean isValidNickname(String nickname) { ... }  
  
private boolean isValidEmail(String email) { ... }

Видите ошибку? А она есть. Мы провалидировали email 2 раза. Второй раз валидатором для никнейма. Скорее всего if был скопирован, функцию валидации и сообщение об ошибке поменяли, а параметр забыли. Это довольно неприятная ошибка, т. к. код не просто запустится, но и будет корректно работать на позитивных сценариях. Найти ошибку можно только попытавшись специально прислать данные с не валидным никнеймом.

Решение — обернуть примитивы в классы:

public class Email {  
    private final String value;  
  
    public Email(String value) {  
        if (!isValid(value)) {  
            throw new IllegalArgumentException("Invalid email");  
        }  
        this.value = value;  
    }  
  
    private boolean isValid(String email) {...}  
}  
  
public class Nickname {  
    private final String value;  
  
    public Nickname(String value) {  
        if (!isValid(value)) {  
            throw new IllegalArgumentException("Invalid nickname");  
        }  
        this.value = value;  
    }  
  
    private boolean isValid(String nickname) {...}  
}
public Response registerUser(RegistrationRequest request) {  
    Email email;
    Nickname nickname;
    
    try {  
        email = new Email(request.getEmail());  
        nickname = new Nickname(request.getNickname());  		
    } catch (IllegalArgumentException e) {  
        return Response.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());  
    }
    
    User user = new User(email, nickname);  
	userRepository.save(user);  
  
	return Response.ok("User registered successfully");  
}

Логика валидации инкапсулирована в классе вместе с данными, которые должны валидироваться. Больше нельзя случайно в параметр email отправить никнейм т.к. у них разные типы.

Мартин Фаулер в книге "Рефакторинг. Улучшение существующего кода" рассматривает несколько частных случаев этой техники, в которых следует заменить переменную классом или нeсколькими классами.

Объявление новых типов

Посмотрите на следующий код.

int delay = 5
time.sleep(delay)

На какое время выполнение приостановится? 5 миллисекунд? 5 секунд? Код не содержит этой информации. В Java, например, в функцию Thread.sleep нужно передавать миллисекунды, а в Python секунды.

Часто иcпользуемое исправление этой неоднозначности выглядит так:

int delayMillis = 5
time.sleep(delayMillis)

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

Как же выглядит более безопасное решение? Нужно создать собственный тип Duration, и сделать так, чтобы функция sleep принимала аргумент типа Duration. Наименьшей единицей измерения примем наносекунду и для удобства создадим константы. Язык Go.

type Duration int64

const (  
    Nanosecond  Duration = 1  
    Microsecond          = 1000 * Nanosecond  
    Millisecond          = 1000 * Microsecond  
    Second               = 1000 * Millisecond  
    Minute               = 60 * Second  
    Hour                 = 60 * Minute  
)

Этот код скопирован из пакета time стандартной библиотеки Go. И теперь при указании задержки мы используем константы типа Duration с говорящими именами.

delay := 5 * time.Second
time.Sleep(delay)

В эту функцию мы не можем отправить int.

delay := 5  
time.Sleep(delay) // Ошибка cannot use delay (variable of type int) 
                  //        as time.Duration value in argument to time.Sleep   

Можно подумать, что это аналогично наследованию. Если бы мы каким-то образом отнаследовались от типа int, то поведение было бы схожим. Но важное отличие состоит в том, что переменная типа Duration не присваеваема типу int.

delay := 5 * time.Second
var i int  
i = delay // Ошибка cannot use delay (variable of int64 type time.Duration)
          //        as int64 value in assignment

Автоматического upcasting не происходит, как было бы с наследованием. А отличие от класса-обёртки заключается в том, что у нас не создаётся вложенности.

Кстати, вот как с помощью объявления типов можно переписать код из начала статьи. Объявим разные типы для id разных сущностей.

type UserID int
type ItemID int

func processOrder(userID UserID, itemID ItemID) {
	...
}

userID := UserID(intUserID)
itemID := ItemID(intItemID)

processOrder(itemID, userID) // Ошибки
// cannot use itemID (variable of int type ItemID) as UserID value in argument to processOrder
// cannot use userID (variable of int type UserID) as ItemID value in argument to processOrder

processOrder(userID, itemID) // ok

Добавилось всего 2 строки, не считая конвертации. А перепутать местами параметры больше не получится.

Обязательные именованные аргументы

В языках с динамической типизацией, где контроля типов на уровне компилятора нет по определению, мы не можем использовать новые типы для повышения безопасности. Большой процент ошибок из-за Primitive obsession возникает в момент передачи параметров в функцию. Для таких случаев существует техника keyword-only параметры функций.

Например в Python, если указать * первым аргументом в функции, то её можно будет вызвать передав только именованные параметры.

def differentiated_payment(*, principal, annual_rate, months, target_month):
    monthly_rate = annual_rate / 100 / 12
    main_payment = principal / months
    interest_payment = (principal - (target_month - 1) * main_payment) * monthly_rate
    return main_payment + interest_payment if target_month <= months else None


res = differentiated_payment(
    principal=1_000_000,
    annual_rate=10,
    months=12,
    target_month=24
) # ok

res = differentiated_payment(1_000_000, 10, 12, 24) # Ошибка 
# TypeError: differentiated_payment() takes 0 positional arguments but 4 were given

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

Мы разобрали на примерах способы борьбы с Primitive obsession. Надеюсь, эта статья поможет сделать ваш код более безопасным.

Tags:
Hubs:
Total votes 17: ↑17 and ↓0+20
Comments10

Articles