Статьи

Цейлон: Объект строительства и проверки

При портировании кода Java на Ceylon я иногда сталкиваюсь с классами Java, где конструктор смешивает проверку с инициализацией . Давайте проиллюстрируем, что я имею в виду, с помощью простого, но очень надуманного примера.

Какой-то плохой код

Рассмотрим этот класс Java. (Старайтесь не писать такой код дома, детки!)

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Period {
 
    private final Date startDate;
    private final Date endDate;
 
    //returns null if the given String
    //does not represent a valid Date
    private Date parseDate(String date) {
       ...
    }
 
    public Period(String start, String end) {
        startDate = parseDate(start);
        endDate = parseDate(end);
    }
 
    public boolean isValid() {
        return startDate!=null && endDate!=null;
    }
 
    public Date getStartDate() {
        if (startDate==null)
            throw new IllegalStateException();
        return startDate;
    }
 
    public Date getEndDate() {
        if (endDate==null)
            throw new IllegalStateException();
        return endDate;
    }
}

Эй, я предупреждал тебя, что это будет изобретено. Но на самом деле нередко находить подобные вещи в реальном Java-коде. Метод parseDate () не работает, мы все еще получаем экземпляр Period . Но Period мы получаем, фактически не находится в «действительном» состоянии. Что я имею в виду именно?

Ну, я бы сказал, что объект находится в недопустимом состоянии, если он не может осмысленно реагировать на свои публичные операции. В этом случае getStartDate() и getEndDate() могут IllegalStateException , которое я бы посчитал не «значимым».
Еще один способ взглянуть на это — то, что мы имеем здесь, это сбой безопасности типов в дизайне Period . Непроверенные исключения представляют собой «дыру» в системе типов. Таким образом, более типичный дизайн для Period будет таким, в котором никогда не используются непроверенные исключения — в этом случае исключение IllegalStateException не IllegalStateException .

(На самом деле, на практике в реальном коде я с большей вероятностью сталкиваюсь с getStartDate() который не проверяет null , а фактически приводит к NullPointerException ниже, что еще хуже.)
Мы можем легко перевести вышеуказанный класс Period на Цейлон:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
shared class Period(String start, String end) {
 
    //returns null if the given String
    //does not represent a valid Date
    Date? parseDate(String date) => ... ;
 
    value maybeStartDate = parseDate(start);
    value maybeEndDate = parseDate(end);
 
    shared Boolean valid
        => maybeStartDate exists
        && maybeEndDate exists;
 
    shared Date startDate {
        assert (exists maybeStartDate);
        return maybeStartDate;
    }
 
    shared Date endDate {
        assert (exists maybeEndDate);
        return maybeEndDate;
    }
}

И, конечно, этот код страдает той же проблемой, что и исходный код Java. Два assert иона кричат ​​нам, что существует проблема с типобезопасностью кода.

Улучшение кода Java

Как мы могли бы улучшить этот код в Java. Хорошо, вот случай, когда проверенные исключения Java были бы действительно разумным решением! Мы могли бы немного изменить Period чтобы выдать проверенное исключение из его конструктора

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Period {
 
    private final Date startDate;
    private final Date endDate;
 
    //throws if the given String
    //does not represent a valid Date
    private Date parseDate(String date)
            throws DateFormatException {
       ...
    }
 
    public Period(String start, String end)
            throws DateFormatException {
        startDate = parseDate(start);
        endDate = parseDate(end);
    }
 
    public Date getStartDate() {
        return startDate;
    }
 
    public Date getEndDate() {
        return endDate;
    }
}

Теперь, с помощью этого решения, мы никогда не сможем получить Period в недопустимом состоянии, и код, который создает экземпляр Period , обязан компилятором обрабатывать случай неверного ввода, catch где-то DateFormatException .

1
2
3
4
5
6
7
try {
    Period p = new Period(start, end);
    ...
}
catch (DateFormatException dfe) {
    ...
}

Это хорошее, отличное и правильное использование проверенных исключений, и, к сожалению, я редко нахожу код Java, который использует проверенные исключения, подобные этому.

Делаем код Цейлона лучше

Как насчет Цейлона? У Цейлона нет проверенных исключений, поэтому нам придется искать другое решение. Как правило, в тех случаях, когда Java будет вызывать использование функции, которая выдает проверенное исключение, Ceylon будет вызывать использование функции, которая возвращает тип объединения. Поскольку инициализатор класса не может возвращать какой-либо тип, кроме самого класса, нам необходимо извлечь некоторую смешанную логику инициализации / проверки в функцию фабрики.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
//returns DateFormatError if the given
//String does not represent a valid Date
Date|DateFormatError parseDate(String date) => ... ;
 
shared Period|DateFormatError parsePeriod
        (String start, String end) {
    value startDate = parseDate(start);
    if (is DateFormatError startDate) {
        return startDate;
    }
    value endDate = parseDate(end);
    if (is DateFormatError endDate)  {
        return endDate;
    }
    return Period(startDate, endDate);
}
 
shared class Period(startDate, endDate) {
    shared Date startDate;
    shared Date endDate;
}

Система типов заставляет вызывающего иметь дело с DateFormatError :

1
2
3
4
5
6
7
value p = parsePeriod(start, end);
if (is DateFormatError p) {
    ...
}
else {
    ...
}

Или, если мы не заботимся о реальной проблеме с данным форматом даты (вероятно, учитывая, что исходный код, из которого мы работали, потеряли эту информацию), мы могли бы просто использовать Null вместо DateFormatError :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
/returns null if the given String
//does not represent a valid Date
Date? parseDate(String date) => ... ;
 
shared Period? parsePeriod(String start, String end)
    => if (exists startDate = parseDate(start),
           exists endDate = parseDate(end))
       then Period(startDate, endDate)
       else null;
 
shared class Period(startDate, endDate) {
    shared Date startDate;
    shared Date endDate;
}

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

Резюме

В заключении:

  • Попробуйте отделить валидацию от инициализации, где это целесообразно.
  • Логика валидации обычно не принадлежит конструкторам (особенно не в Цейлоне).
  • Не создавайте объекты в «недопустимых» состояниях.
  • «Недопустимое» состояние иногда может быть обнаружено путем поиска сбоев безопасности типов.
  • В Java разумной альтернативой является конструктор или фабричная функция, которая выдает проверенное исключение.
  • В Цейлоне фабричная функция, которая возвращает тип объединения, является разумной альтернативой.