Статьи

Конструкторы на Цейлоне

Начиная с самых ранних версий Цейлона, мы поддерживали упрощенный синтаксис для инициализации класса, где параметры класса перечислены сразу после имени класса, а логика инициализации идет непосредственно в теле класса.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
class Color(shared Integer rgba) {
 
    assert (0 <= rgba <= #FFFFFFFF);
 
    function encodedValue(Integer slot)
            => rgba.rightLogicalShift(8*slot).and(#FF);
 
    shared Integer alpha => encodedValue(3);
 
    shared Integer red => encodedValue(2);
    shared Integer green => encodedValue(1);
    shared Integer blue => encodedValue(0);
 
    function hex(Integer int) => formatInteger(int, 16);
 
    string => "Color { \
               alpha=``hex(alpha)``, \
               red=``hex(red)``, \
               green=``hex(green)``, \
               blue=``hex(blue)`` }";
 
}

Мы можем создать экземпляр класса следующим образом:

1
Color red = Color(#FFFF0000);

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

Однако, как мы видели за последние несколько лет написания кода на Цейлоне, бывают моменты, когда мы действительно ценим возможность написать класс с несколькими путями инициализации, что-то вроде конструкторов в Java, C # или C ++. Чтобы было ясно, в подавляющем большинстве случаев — я бы сказал, что это более 90% классов — конструкторы не нужны и неудобны. Но нам все еще нужно хорошее решение для оставшихся хитрых дел.

Мы столкнулись с несколькими особенно убедительными случаями:

  • класс Array в ceylon.language , который может быть размещен со списком элементов или с размером и значением одного элемента, и
  • клонирование конструкторов копирования, используемых, например, для реализации HashMap.clone() и HashSet.clone() .

К сожалению, я всегда находил дизайн конструкторов, унаследованных от C ++ Java и C #, немного странным и невыразительным. Итак, прежде чем я расскажу вам, что мы сделали с конструкторами в Цейлоне 1.2, позвольте мне начать с объяснения того, что я считаю неправильным с конструкторами в Java.

Что не так с конструкторами в Java?

Как упоминалось выше, самая большая проблема с синтаксисом конструктора в языках, заимствованных из C ++, заключается в том, что в общем случае класса с одним конструктором параметры этого конструктора недоступны в теле класса, что приводит к ужасному код, подобный следующему:

01
02
03
04
05
06
07
08
09
10
11
class Point {
    public float x;
    public float y;
    public Point(float x, float y) {
        this.x = x;
        this.y = y;
    }
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}

Это больно. К счастью, мы уже избавили эту боль на Цейлоне.

1
2
3
class Point(shared Float x, shared Float y) {
    string => "(``x``, ``y``)";
}

Итак, давайте посмотрим на некоторые дополнительные проблемы с конструкторами в Java.

Начнем с того, что синтаксис неправильный. В C-подобных языках грамматика для объявления:

1
Modifier* (Keyword|Type) Identifier OtherStuff

Конструкторы, как ни странно, не соответствуют этой общей схеме, поскольку были закреплены позже.

Во-вторых, все конструкторы класса должны иметь одинаковые имена. Это выглядит как довольно странное ограничение:

  • Если все они имеют одинаковые имена, почему бы не объявить их с ключевым словом вместо идентификатора? СУХОЙ, кто-нибудь?
  • Это ограничение, которое лишает меня выразительности. Вместо new ColorWithRGBAndAlpha(r,g,b,a) , давая мне подсказку относительно семантики аргументов, я пишу только new Color(r,g,b,a) , и читатель остается угадывать.
  • Конструкторы, таким образом, сталкиваются с полностью сломанной поддержкой Java для перегрузки. У меня не может быть конструктора, который принимает List<Float> а другой — List<Integer> , поскольку эти два типа параметров имеют одинаковое стирание.
  • Ссылки на конструктор ( Class::new в Java) могут быть неоднозначными, в зависимости от контекста.

В-третьих, конструкторы не обязаны инициализировать переменные экземпляра класса. Все типы Java имеют значение по умолчанию (ноль или ноль), и если вы забудете инициализировать переменную экземпляра в Java, вы получите NullPointerException или, что еще хуже, неправильное нулевое значение во время выполнения. Эти проблемы, безусловно, относятся к классу проблем, которые, как я ожидаю, сможет обнаруживать статическая система типов, и, действительно, в других контекстах Java обнаруживает неинициализированные переменные.

Также обратите внимание, что это будет еще большей проблемой в Цейлоне, потому что большинство типов не имеют null в качестве экземпляра, поэтому нет очевидного значения «по умолчанию».

Как обычно, моя цель здесь не в том, чтобы разбить Java, а в том, чтобы объяснить, почему мы сделали все по-другому на Цейлоне.

Именованные конструкторы и конструкторы по умолчанию

Напротив, недавно введенный синтаксис для конструкторов в Ceylon является регулярным, выразительным и не зависит от перегрузки (который не поддерживает Ceylon, за исключением случаев взаимодействия с собственным кодом Java). Вот основной синтаксис для конструктора:

1
2
3
4
new withFooAndBar(Foo foo, Bar bar)
        extends anotherConstructor(foo) {
    //do stuff
}

Когда класс, к которому принадлежит конструктор, напрямую расширяет Basic , предложение extends является необязательным.

И вот пример того, как это используется:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Color {
 
    shared Integer rgba;
 
    //default constructor
    shared new (Integer rgba) {
        assert (0 <= rgba <= #FFFFFFFF);
        this.rgba = rgba;
    }
 
    //named constructor
    shared new withRGB(
        Integer red, Integer green, Integer blue,
        Integer alpha = #FF) {
        assert (0 <= red <= #FF,
                0 <= green <= #FF,
                0 <= blue <= #FF);
        rgba =
                alpha.leftLogicalShift(24) +
                red.leftLogicalShift(16) +
                green.leftLogicalShift(8) +
                blue;
    }
 
    //another named constructor
    shared new withRGBIntensities(
        Float red, Float green, Float blue,
        Float alpha = 1.0) {
        assert (0.0 <= red <= 1.0,
                0.0 <= green <= 1.0,
                0.0 <= blue <= 1.0);
        function int(Float intensity)
                => (intensity*#FF).integer;
        rgba =
                int(alpha).leftLogicalShift(24) +
                int(red).leftLogicalShift(16) +
                int(green).leftLogicalShift(8) +
                int(blue);
    }
 
    function encodedValue(Integer slot)
            => rgba.rightLogicalShift(8*slot).and(#FF);
 
    shared Integer alpha => encodedValue(3);
 
    shared Integer red => encodedValue(2);
    shared Integer green => encodedValue(1);
    shared Integer blue => encodedValue(0);
 
    function hex(Integer int) => formatInteger(int, 16);
 
    string => "Color { \
               alpha=``hex(alpha)``, \
               red=``hex(red)``, \
               green=``hex(green)``, \
               blue=``hex(blue)`` }";
 
}

Объявления конструктора обозначаются ключевым словом new и имеют имя, которое начинается со строчной буквы. Мы называем конструктор так:

1
Color red = Color.withRGBIntensities(1.0, 0.0, 0.0);

Или, используя именованные аргументы, вот так:

1
2
3
4
5
6
Color red =
    Color.withRGBIntensities {
        red = 1.0;
        green = 0.0;
        blue = 0.0;
    };

Ссылка на функцию конструктора имеет естественный синтаксис:

1
2
Color(Float,Float,Float) createColor
        = Color.withRGBIntensities;

Класс может иметь конструктор, называемый конструктором по умолчанию , без имени. Инстанцирование через конструктор по умолчанию работает так же, как инстанцирование класса без конструкторов:

1
Color red = Color(#FFFF0000);

Класс не обязательно должен иметь конструктор по умолчанию, но большинство классов будет иметь его.

Зачем нам нужна концепция конструктора по умолчанию? Ну, потому что класс с конструкторами может не иметь списка параметров . Подождите, давайте остановимся и еще раз подчеркнем эту оговорку, потому что она важна:

Вы не можете добавлять конструкторы в класс со списком параметров! Вместо этого вы должны сначала переписать класс, чтобы использовать «конструктор по умолчанию» для его «нормальной» логики инициализации.

Однако класс с конструкторами все еще может иметь логику инициализации непосредственно в теле класса. Например, следующее абсолютно законно:

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
33
34
35
36
37
38
39
40
41
42
class Color {
 
    shared Integer rgba;
 
    shared new (Integer rgba) {
        this.rgba = rgba;
    }
 
    shared new withRGB(
        Integer red, Integer green, Integer blue,
        Integer alpha = #FF) {
        assert (0 <= red <= #FF,
                0 <= green <= #FF,
                0 <= blue <= #FF);
        rgba =
                alpha.leftLogicalShift(24) +
                red.leftLogicalShift(16) +
                green.leftLogicalShift(8) +
                blue;
    }
 
    shared new withRGBIntensities(
        Float red, Float green, Float blue,
        Float alpha = 1.0) {
        assert (0.0 <= red <= 1.0,
                0.0 <= green <= 1.0,
                0.0 <= blue <= 1.0);
        function int(Float intensity)
                => (intensity*#FF).integer;
        rgba =
                int(alpha).leftLogicalShift(24) +
                int(red).leftLogicalShift(16) +
                int(green).leftLogicalShift(8) +
                int(blue);
    }
 
    //executed for every constructor
    assert (0 <= rgba <= #FFFFFFFF);
 
    //other members
    ...
}

Последний оператор assert выполняется каждый раз, когда создается экземпляр класса.

Конструкторы значений

Конструкторы, которые мы только что видели, называются вызываемыми конструкторами в спецификации языка, потому что они объявляют параметры. У нас также есть конструкторы значений , которые не объявляют параметры и которые выполняются один раз, когда конструктор вычисляется в первый раз в контексте, к которому принадлежит класс. Для класса верхнего уровня, конструктор значения является одноэлементным.

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
class Color {
 
    shared Integer rgba;
 
    //default constructor
    shared new (Integer rgba) {
        this.rgba = rgba;
    }
 
    //value constructors
 
    shared new white {
        rgba = #FFFFFFFF;
    }
 
    shared new red {
        rgba = #FFFF0000;
    }
 
    shared new green {
        rgba = #FF00FF00;
    }
 
    shared new blue {
        rgba = #FF0000FF;
    }
 
    //etc
    ...
 
}

Мы можем использовать конструктор значений следующим образом:

1
Color red = Color.red;

Иногда конструкторы класса разделяют определенную логику инициализации. Если эта логика не зависит от параметров класса, мы можем поместить ее непосредственно в тело класса, как мы уже видели. Но если это зависит от параметров, нам часто нужно идти другим путем.

Делегация конструктора

Чтобы облегчить повторное использование логики инициализации в классе, полезно разрешить конструктору делегировать другому конструктору того же класса. Для этого мы используем предложение extends :

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Integer int(Float intensity)
        => (intensity*#FF).integer;
 
class Color {
 
    shared Integer rgba;
 
    shared new (Integer rgba) {
        this.rgba = rgba;
    }
 
    //value constructors delegate to the default constructor
 
    shared new white
            extends Color(#FFFFFFFF) {}
 
    shared new red
            extends Color(#FFFF0000) {}
 
    shared new green
            extends Color(#FF00FF00) {}
 
    shared new blue
            extends Color(#FF0000FF) {}
 
    shared new withRGB(
        Integer red, Integer green, Integer blue,
        Integer alpha = #FF) {
        assert (0 <= red <= #FF,
                0 <= green <= #FF,
                0 <= blue <= #FF);
        rgba =
                alpha.leftLogicalShift(24) +
                red.leftLogicalShift(16) +
                green.leftLogicalShift(8) +
                blue;
    }
 
    shared new withRGBIntensities(
        Float red, Float green, Float blue,
        Float alpha = 1.0)
            //delegate to other named constructor
            extends withRGB(int(red),
                            int(green),
                            int(blue),
                            int(alpha)) {}
 
    assert (0 <= rgba <= #FFFFFFFF);
 
    //other members
    ...
}

Конструктор может делегировать только конструктору, определенному ранее в теле класса. Обратите внимание, что мы написали, extends Color(#FFFFFFFF) для делегирования конструктору Color по умолчанию.

Определенная инициализация и частичные конструкторы

Обычный конструктор, такой как Color.withRGB() или Color.withRGBIntensities() , отвечает за инициализацию каждой ссылки на значение, принадлежащей классу:

  • shared , или
  • используется («захвачен») другим членом класса.

Компилятор Ceylon применяет эту ответственность во время компиляции и отклоняет код, если не может доказать, что каждая ссылка на значение полностью инициализирована, либо:

  • каждый обычный конструктор, или
  • в теле самого класса.

Это правило затруднило бы выделение общей логики, содержащейся в конструкторах, если бы не понятие частичного конструктора . Для частичного конструктора требование полной инициализации всех ссылок смягчено. Но частичный конструктор нельзя использовать для непосредственного создания экземпляра класса. Он может быть вызван только из предложения extends другого конструктора того же класса. Частичный конструктор обозначается abstract аннотацией: Вот надуманный пример:

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
class ColoredPoint {
    shared Point point;
    shared Color color;
 
    //partial constructor
    abstract new withColor(Color color) {
        this.color = color;
    }
 
    shared new forCartesianCoords(Color color,
        Float x, Float y)
            //delegate to partial constructor
            extends withColor(color) {
        point = Point.cartesian(x, y);
    }
 
    shared new forPolarCoords(Color color,
        Float r, Float theta)
            //delegate to partial constructor
            extends withColor(color) {
        point = Point.polar(r, theta);
    }
 
    ...
 
}

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

Конструкторы и наследование

Класс может расширять класс конструкторами, например:

1
2
3
4
5
class ColoredPoint2(color, Float x, Float y)
        extends Point.cartesian(x, y) {
    shared Color color;
    ...
}

Более интересный случай, когда сам расширяющий класс имеет конструкторы:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
class ColoredPoint extends Point {
    shared Color color;
 
    shared new forCartesianCoords(Color color,
        Float x, Float y)
            //delegate to Point.cartesian()
            extends cartesian(x, y) {
        this.color = color;
    }
 
    shared new forPolarCoords(Color color,
        Float r, Float theta)
            //delegate to Point.polar()
            extends polar(r, theta) {
        this.color = color;
    }
 
    ...
}

В этом примере конструкторы делегируют непосредственно конструкторам суперкласса.

Упорядочение логики инициализации

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

Ну нет. Общий принцип инициализации в Цейлоне остается неизменным: инициализация всегда проходит сверху вниз, что позволяет проверке типов проверять, что каждое value инициализируется перед его использованием.
Рассмотрим этот класс:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
class Class {
    print(1);
    abstract new partial() {
        print(2);
    }
    print(3);
    shared new () extends partial() {
        print(4);
    }
    print(5);
    shared new create() extends partial() {
        print(6);
    }
    print(7);
}

Вызов Class() приводит к следующему выводу:

1
2
3
4
5
6
1
2
3
4
5
7

Вызов Class.create() приводит к следующим выводам:

1
2
3
4
5
6
1
2
3
5
6
7

Все довольно упорядоченно и предсказуемо! В комментариях Дэвид Хаген предлагает способ понять, как работает делегирование конструктора и упорядочение.

Использование конструкторов значений для эмуляции перечислений

Возможно, вы уже заметили, что если класс имеет только конструкторы значений, он очень похож на enum Java.

01
02
03
04
05
06
07
08
09
10
11
12
13
shared class Day {
    shared actual String string;
    abstract new named(String name) {
        string = name;
    }
    shared new sunday extends named("SUNDAY") {}
    shared new monday extends named("MONDAY") {}
    shared new tuesday extends named("TUESDAY") {}
    shared new wednesday extends named("WEDNESDAY") {}
    shared new thursday extends named("THURSDAY") {}
    shared new friday extends named("FRIDAY") {}
    shared new saturday extends named("SATURDAY") {}
}

Поэтому мы позволяем вам использовать конструкторы значений в операторе switch . Возможность switch конструкторов значений может рассматриваться как расширение ранее существовавшего средства switch буквальных значений типов, таких как Integer , Character и String .

01
02
03
04
05
06
07
08
09
10
11
12
13
Day day = ... ;
String message;
switch (day)
case (Day.friday) {
    message = "thank god";
}
case (Day.sunday | Day.saturday) {
    message = "we could be having this conversation with beer";
}
else {
    message = "need more coffee";
}
print(message);

Но когда мы заметили сходство с enum Java, мы решили пойти дальше, чем то, что предлагает здесь Java. Перечисления Java открыты в том смысле, что оператор switch который охватывает все перечисляемые значения enum должен по-прежнему включать регистр по default который считается исчерпывающим при определенном присваивании и определенной проверке возврата. Но в Цейлоне у нас также есть понятие закрытых перечислимых типов, где регистр «по умолчанию» — условие else оператора Ceylon switch — может быть опущен, но весь switch все равно будет считаться исчерпывающим. Примечание: разработчики API должны быть осторожны, чтобы только «закрыть» перечисление, которое не будет увеличивать конструкторы новых значений в будущих версиях API. Day и Boolean являются хорошими примерами закрытых перечислений. ErrorType — это примеры открытого перечисления. Если мы добавим предложение к Day , оно будет считаться закрытым перечислением.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
shared class Day
        of sunday | monday | tuesday | wednesday |
           thursday | friday | saturday {
    shared actual String string;
    abstract new named(String name) {
        string = name;
    }
    shared new sunday extends named("SUNDAY") {}
    shared new monday extends named("MONDAY") {}
    shared new tuesday extends named("TUESDAY") {}
    shared new wednesday extends named("WEDNESDAY") {}
    shared new thursday extends named("THURSDAY") {}
    shared new friday extends named("FRIDAY") {}
    shared new saturday extends named("SATURDAY") {}
}

Теперь Ceylon будет рассматривать оператор switch который охватывает все конструкторы значений, как исчерпывающий switch , и мы можем написать следующий код, не нуждаясь в предложении else :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
Day day = ... ;
String message;
switch (day)
case (Day.monday | Day.tuesday |
      Day.wednesday | Day.thursday) {
    message = "need more coffee";
}
case (Day.friday) {
    message = "thank god";
}
case (Day.sunday | Day.saturday) {
    message = "we could be having this conversation with beer";
}
print(message);

Это альтернатива существующему шаблону для эмуляции enum стиле Java .

Последнее слово

Дизайн, который я представил здесь, является окончательным результатом мыслительного процесса, который длился пять лет. Я лично обнаружил, что это удивительно трудная проблема, которую нужно решать принципиальным образом. Какое-то время я надеялся, что вообще не нужно иметь конструкторов на языке. Но в конечном итоге я очень доволен конечным результатом. Это кажется мне не только принципиальным и совместимым с остальной частью языка, но также очень выразительным и мощным.