Distracted men meme: New clas modifier
Distracted men meme: New class modifier

Совсем недавно команда разработки Dart выпустила 3-ю версию языка, которая привнесла много новых крутых штук и возможностей. Одним из нововведений стали модификаторы классов, которые довольно сильно расширили выразительные возможности Dart. С одной стороны, новые модификаторы ложатся в стройную картину и даже логичны; с другой — чувствуется некоторая многословность ( abstract interface class вместо общепринятого interface) и появление ряда ограничений, которых ранее не было. Новые модификаторы классов безусловно интересны и требуют внимания, но сегодня мы будем говорить не о них). Оставим уже вышедшие модификаторы для одной из следующих статей. Логично задаться вопросом: о чем тогда сейчас пойдет речь? Заинтригованы? Тогда добро пожаловать под кат.

Сегодня мы рассмотрим новый (скорее даже очередной) модификатор класса, который пока что еще находится в стадии реализации (accepted proposal, если быть точнее), но уже выглядит крайне полезным. Не буду томить — речь идет об инлайн классах (inline class). Что это за зверь такой? Чем он может быть полезен в повседневной разработке? Как им пользоваться? Именно об этом данная заметка. Ну, а пока попробуйте по названию фичи хотя бы примерно определить (угадать), что эта фича делает.

Прежде чем начать углубляться в тему, стоит задаться следующим вопросом: «А не новинка ли это? Или уже кто‑то раньше реализовывал нечто похожее?». И ответ на этот вопрос, как это очень часто бывает: ничто не ново под луной. Другими словами, Dart не является первопроходцем в этом вопросе. Поэтому давайте рассмотрим, как подобная фича реализована в других языках, как она в них используется и чем полезна; ну, а уже потом вернемся к ее потенциальной реализации в языке Dart и другим особенностям.

Abstracts in Haxe

Первое мое знакомство с подобной фичей произошло в широко известном в узких кругах языке Haxe. Для тех, кто впервые слышит это имя, поясню. Haxe — это язык программирования, который позволяет создавать приложения и генерировать (при необходимости) код для разных платформ (и языков программирования) на основе единой кодовой базы. Haxe — это примерно как Flutter, только среди языков программирования. Я ранее уже делал небольшой обзор этого языка и его возможностей, и если вам интересно, с ним можно ознакомиться здесь. И хотя с тех пор уже много воды утекло, и Haxe не стоит на месте и развивается, в любом случае некоторое представление об этом, безусловно замечательном, языке программирования по этой статье сделать можно.

Вернемся к нашим баранам классам. В Haxe есть тип данных, который называется Abstract. Да, с неймингом здесь не очень. Ничего общего с абстрактными классами этот тип не имеет. А название, безусловно, может ввести в заблуждение. Ключевое слово abstract позволяет вводить новые полноценные (с точки зрения системы типов) типы на основе уже имеющихся. Звучит сложно, но по факту это не так. Абстракты в Haxe можно отнести к так называемым zero cost abstraction конструкциям. Под ними подразумевают выразительные конструкции языка и разного рода синтаксический сахар, за который не приходится «платить» run‑time производительностью. Фактически, такие типы в Haxe существуют только на этапе компиляции, а в run‑time используются базовые типы. За счет чего, собственно, мы и можем говорить про zero cost abstractions.

Простейший пример абстракта на Haxe может выглядеть так:

abstract MyInt(Int) {
  inline public function new(i:Int) {
    this = i;
  }
}

Здесь мы объявили новый абстрактный тип MyInt, который на этапе выполнения будет обычным целым типом Int, который в терминах Haxe называется конкретным типом (concrete type). Другими словами, мы ввели новый тип с именем MyInt, который, по своей сути, с одной стороны является полностью обособленным новым типом (на этапе компиляции), но по факту (во время выполнения) вся информация об этом типе стирается и используется обычный тип целых чисел. Простейший пример использования:

class Main {
  static public function main() {
    var a = new MyInt(12);
    trace(a); // 12
  }
}

Запустив этот код в консоли, мы увидим 12. Пока что ничего сверхъестественного. Но давайте посмотрим следующий пример:

class Main {
    static public function main() {
        var a : MyInt = 12;
        trace(a);
    }
}

Если запустить этот код, то мы увидим следующее сообщение об ошибке:

src/Main.hx:9: characters 3-22 : Int should be MyInt

Оно говорит нам, что использовать целые числа там, где ожидается наш новый тип MyInt, нельзя. Таким образом, несмотря на то, что по факту (буквально в следующем абзаце мы в этом убедимся лично) у нас целочисленная переменная, компилятор ее воспринимает как полноценный (по крайней мере на этапе проверки типов) тип. Собственно, этим абстракты в Haxe и отличаются от алиасов (псевдонимов) типов. И в этом их сила. Абстракты смогли объединить плюсы настоящих типов и алиасов, при этом избежав недостатков как первых, так и вторых.

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

abstract MyInt(Int) {
    inline public function new(i:Int) {
        this = i;
    }
}

class RealClassInt {
    final field:Int;

    public function new(field:Int) {
        this.field = field;
    }

    @override public function toString():String {
        return Std.string(field);
    }
}

class Main {
    static public function main() {
        var a : MyInt = new MyInt(12);
        var b : RealClassInt = new RealClassInt(13);
        trace(a);
        trace(b);
    }
}

Здесь мы также добавили настоящий полноценный класс‑обертку RealClassInt, чтобы мы могли сравнить результирующий код. Если мы скомпилируем наш код в JS, то получим примерно (в зависимости от версии компилятора) следующее:

// ...

var MyInt = {};
MyInt._new = function(i) {
    return i;
};
var RealClassInt = function(field) {
    this.field = field;
};
RealClassInt.__name__ = true;
RealClassInt.prototype = {
    toString: function() {
        return Std.string(this.field);
    }
};
var Main = function() { };
Main.__name__ = true;
Main.main = function() {
    var a = 12;
    var b = new RealClassInt(13);
    console.log("Main.hx:23:",a);
    console.log("Main.hx:24:",b);
};

// ...

Нас волнует код функции main. И, как нетрудно заметить в первом случае, где в исходном коде используется абстракт, в переменную a просто присваивается примитивное значение 12, в то время, как в переменную b присваивается полноценный объект с соответствующим полем. Примерно аналогично будет выглядеть соответсвующий код на Java:

import haxe.root.*;

@SuppressWarnings(value={"rawtypes", "unchecked"})
public class Main extends haxe.lang.HxObject
{
    // ...
    
    public static void main()
    {
        int a = ((int) (12) );
        haxe.root.RealClassInt b = new haxe.root.RealClassInt(((int) (13) ));
        haxe.Log.trace.__hx_invoke2_o(((double) (a) ), haxe.lang.Runtime.undefined, 0.0, new haxe.lang.DynamicObject(new java.lang.String[]{"className", "fileName", "methodName"}, new java.lang.Object[]{"Main", "Main.hx", "main"}, new java.lang.String[]{"lineNumber"}, new double[]{((double) (((double) (23) )) )}));
        haxe.Log.trace.__hx_invoke2_o(0.0, b, 0.0, new haxe.lang.DynamicObject(new java.lang.String[]{"className", "fileName", "methodName"}, new java.lang.Object[]{"Main", "Main.hx", "main"}, new java.lang.String[]{"lineNumber"}, new double[]{((double) (((double) (24) )) )}));
    }
}

Ровно точно так же в первом случае используется примитивный тип Int, и обертки не создается. Аналогичный код на C# выглядит так:

// Generated by Haxe 4.3.1

// ...
#pragma warning disable 109, 114, 219, 429, 168, 162
namespace _Main {
    public sealed class MyInt_Impl_ {
        [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
        public static int _new(int i) {
            return ((int) (i) );
        }
    }
}

#pragma warning disable 109, 114, 219, 429, 168, 162
public class RealClassInt : global::haxe.lang.HxObject {
    // ...
    public RealClassInt(int field) {
        global::RealClassInt.__hx_ctor__RealClassInt(this, field);
    }
    
    public int field;
    
    public virtual string toString() {
        return global::Std.@string(this.field);
    }
}

public class Main : global::haxe.lang.HxObject {
    public static void main() {
        unchecked {
            int a = ((int) (12) );
            global::RealClassInt b = new global::RealClassInt(((int) (13) ));
            global::haxe.Log.trace.__hx_invoke2_o(((double) (a) ), global::haxe.lang.Runtime.undefined, default(double), new global::haxe.lang.DynamicObject(new int[]{302979532, 1547539107, 1648581351}, new object[]{"main", "Main", "Main.hx"}, new int[]{1981972957}, new double[]{((double) (23) )}));
            global::haxe.Log.trace.__hx_invoke2_o(default(double), b, default(double), new global::haxe.lang.DynamicObject(new int[]{302979532, 1547539107, 1648581351}, new object[]{"main", "Main", "Main.hx"}, new int[]{1981972957}, new double[]{((double) (24) )}));
        }
    }
}

Здесь картина абсолютно такая же. Таким образом, должно стать чуть более понятно, что происходит под «капотом». Фактически, абстракты в Haxe — это синтаксический сахар, который позволяет получить гарантии настоящих типов на этапе компиляции без сопутствующих проблем настоящих классов с производительностью. Логично задать вопрос: где и зачем такое нужно? Самый банальный пример: это возможность ввести типы с соответствующими гарантиями и операциями над ними для избежания потенциальных проблем в коде. Например: можно добавить абстракт Email, который после компиляции превратится в обычную строку, но на этапе компиляции будет гарантировать корректность использования соответствующих полей объектов, функций и так далее, благодаря чему нельзя будет случайно передать где‑то вместо строки, содержащей электронную почту, любую другую строку. А также определить операции, которые будут доступны только для типа Email (а не для всех строк). И бонус сверху: нет проблем с производительностью. Разве не здорово?

Само собой, мы рассмотрели наиболее базовый синтаксис абстрактов в языке Haxe. Но, кроме него, есть куча других возможностей: добавление методов для наших абстрактов, геттеров/сеттеров, объявлять операторы; добавление функционала для явного и неявного преобразования из реального типа в абстракт и обратно; абстракт можно сделать перечислением. Все это и многое другое позволяет делать очень крутые вещи в Haxe.

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

Как уже выше было сказано, Haxe позволяет компилировать под разные языки и платформы. А как только речь заходит о многообразии, то сразу всплывают моменты несостыковки. Допустим, что какая‑то функциональность может существовать в одном языке; но это не значит, что она есть в другом. В таком случае задача компилятора — как‑то все это дело эмулировать. Самый простой пример: в языке Haxe есть тип UInt — беззнаковое целое. Это значит, чтобы мы могли компилировать код на другие языки и платформы, необходимо либо наличие соответствующего типа в целевом языке (например, в C# такой тип есть — uint), либо эмулировать его (например, в Java такого типа нет). И вот для такого случая в стандартной библиотеке объявлен (в зависимости от платформы, разумеется) абстракт, который этим и занимается. Выглядит это так:

@:transitive
abstract UInt(Int) from Int to Int {
    @:op(A + B) private static inline function add(a:UInt, b:UInt):UInt {
        return a.toInt() + b.toInt();
    }

    @:op(A / B) private static inline function div(a:UInt, b:UInt):Float {
        return a.toFloat() / b.toFloat();
    }

    @:op(A * B) private static inline function mul(a:UInt, b:UInt):UInt {
        return a.toInt() * b.toInt();
    }

    @:op(A - B) private static inline function sub(a:UInt, b:UInt):UInt {
        return a.toInt() - b.toInt();
    }

    @:op(A > B)
    private static #if !js inline #end function gt(a:UInt, b:UInt):Bool {
        var aNeg = a.toInt() < 0;
        var bNeg = b.toInt() < 0;
        return if (aNeg != bNeg) aNeg; else a.toInt() > b.toInt();
    }

    @:op(A >= B)
    private static #if !js inline #end function gte(a:UInt, b:UInt):Bool {
        var aNeg = a.toInt() < 0;
        var bNeg = b.toInt() < 0;
        return if (aNeg != bNeg) aNeg; else a.toInt() >= b.toInt();
    }

// ...
}

Этот абстракт реализован поверх существующего целочисленного типа Int. Благодаря его наличию, беззнаковое целое можно использовать и на платформах, не поддерживающих этот тип. Причем, заметьте, прозрачно и с максимальной производительностью. Это как раз тот самый zero cost abstraction в деле.

Еще один яркий пример применения абстрактов. Все мы знаем, что во фреймворке Flutter есть класс, который отвечает за цвет — Color. По своей сути цвет представляет собой 32— битное значение в формате ARGB. Где A — это Alpha — прозрачность, а RGB соответственно Red, Green и Blue. Таким образом, для представления цвета достаточно типа int, но создатели Flutter решили, что отдельный тип‑обертка лучше подходит для этих целей. Таким образом, с одной стороны, это типобезопасность и возможность объявить операции, которые можно использовать только с цветами, а с другой — это некоторая потеря в производительности, если мы будем генерировать и манипулировать цветами (объектами) в run‑time. Но если бы разработчики Flutter имели в своем арсенале такую возможность как абстракты из Haxe, то они ею непременно воспользовались бы (возможно, это даже когда‑нибудь случится). Выглядело бы это примерно так:

/**
 * ARGB color type based on a 32-bit integer.
 */
abstract Color(Int) from Int to Int {
  /**
   * Predefined color constants for common colors.
   */
  public static inline var TRANSPARENT : Color = 0x00000000;
  public static inline var BLACK : Color = 0xff000000;
  public static inline var WHITE : Color = 0xffffffff;
  public static inline var RED : Color = 0xffff0000;
  public static inline var GREEN : Color = 0xff00ff00;
  public static inline var BLUE : Color = 0xff0000ff;
  public static inline var CYAN : Color = 0xff00ffff;
  public static inline var MAGENTA : Color = 0xffff00ff;
  public static inline var YELLOW : Color = 0xffffff00;
  
  /**
   * Performs implicit casting from an ARGB hex string to a color.
   * @param   argb  ARGB hex string
   * @return  Color based on hex string
   */
  @:from public static inline function fromString(argb : String) : Color {
    return new Color(Std.parseInt(argb));
  }
  
  /**
   * Creates a new color from integer color components.
   * @param   a   Alpha channel value
   * @param   r   Red channel value
   * @param   g   Green channel value
   * @param   b   Blue channel value
   * @return  Color based on color components
   */
  public static inline function fromARGBi(a : Int, r : Int, g : Int, b : Int) : Color {
    return new Color((a << 24) | (r << 16) | (g << 8) | b);
  }
  
  /**
   * Creates a new color from floating point color components.
   * @param   a   Alpha channel value
   * @param   r   Red channel value
   * @param   g   Green channel value
   * @param   b   Blue channel value
   * @return  Color based on color components
   */
  public static inline function fromARGBf(a : Float, r : Float, g : Float, b : Float) : Color {
    return fromARGBi(Std.int(a * 255), Std.int(r * 255), Std.int(g * 255), Std.int(b * 255));
  }
  
  /**
   * Constructs a new color.
   * @param   argb  Color formatted as ARGB integer
   */
  inline function new(argb : Int) this = argb;
  
  /**
   * Integer color channel getters and setters.
   */

  public var ai(get, set) : Int;
  inline function get_ai() return (this >> 24) & 0xff;
  inline function set_ai(ai : Int) { this = fromARGBi(ai, ri, gi, bi); return ai; }
  
  public var ri(get, set) : Int;
  inline function get_ri() return (this >> 16) & 0xff;
  inline function set_ri(ri : Int) { this = fromARGBi(ai, ri, gi, bi); return ri; }
  
  public var gi(get, set) : Int;
  inline function get_gi() return (this >> 8) & 0xff;
  inline function set_gi(gi : Int) { this = fromARGBi(ai, ri, gi, bi); return gi; }
  
  public var bi(get, set) : Int;
  inline function get_bi() return this & 0xff;
  inline function set_bi(bi : Int) { this = fromARGBi(ai, ri, gi, bi); return bi; }
  
  /**
   * Floating point color channel getters and setters.
   */

  public var af(get, set) : Float;
  inline function get_af() return ai / 255;
  inline function set_af(af : Float) { this = fromARGBf(af, rf, gf, bf); return af; }
  
  public var rf(get, set) : Float;
  inline function get_rf() return ri / 255;
  inline function set_rf(rf : Float) { this = fromARGBf(af, rf, gf, bf); return rf; }
  
  public var gf(get, set) : Float;
  inline function get_gf() return gi / 255;
  inline function set_gf(gf : Float) { this = fromARGBf(af, rf, gf, bf); return gf; }
  
  public var bf(get, set) : Float;
  inline function get_bf() return bi / 255;
  inline function set_bf(bf : Float) { this = fromARGBf(af, rf, gf, bf); return bf; }
}

А в использовании этот тип мало в чем отличался бы от использования обычного класса-обертки:

// Use a predefined color
var red : Color = Color.RED;

// Use a custom color
var fromInt : Color = 0x98765432;
var fromString : Color = "0xffeeddcc";
var fromIntComponents : Color = Color.fromARGBi(255, 125, 100, 50);
var fromFloatComponents : Color = Color.fromARGBf(1.0, 0.3, 0.7, 0.2);

// Access color channels
fromIntComponents.ri = red.ri;
fromString.af = 1.0;

// Print out the hex values
trace(StringTools.hex(red));
trace(StringTools.hex(fromInt));
trace(StringTools.hex(fromString));
trace(StringTools.hex(fromIntComponents));
trace(StringTools.hex(fromFloatComponents));

Примерно вот так этот код выглядит, если его скомпилировать в JS:

var red = -65536;
var fromInt = -1737075662;
var fromString = Std.parseInt("0xffeeddcc");
var fromIntComponents = -8559566;
var fromFloatComponents = (255. | 0) << 24 | (76.5 | 0) << 16 | (178.5 | 0) << 8 | (51. | 0);
fromIntComponents = -39886;
fromString = (255. | 0) << 24 | ((fromString >> 16 & 255) / 255 * 255 | 0) << 16 | ((fromString >> 8 & 255) / 255 * 255 | 0) << 8 | ((fromString & 255) / 255 * 255 | 0);
console.log(StringTools.hex(red));
console.log(StringTools.hex(fromInt));
console.log(StringTools.hex(fromString));
console.log(StringTools.hex(fromIntComponents));
console.log(StringTools.hex(fromFloatComponents));

Как нетрудно заметить, здесь нет никаких классов, все функции заинлайнены, и все максимально производительно. Разве это не прекрасно? Такая простая (когда познакомишься поближе) идея и настолько мощная. Почему же до сих пор не так много ЯП реализовали ее? И это хороший вопрос.

I robot meme: this is the right question
I robot meme: this is the right question

Мы выше уже кратенько рассмотрели пример с Email. Но на самом деле мы можем сделать еще лучше. Можно завести два типа: UncomfirmedEmail и ConfirmedEmail, благодаря чему на корню извести проблему с доступом к каким-то данным с неподтвержденным электронным адресом. Для этого функции и методы должны работать с соответствующим типом электронной почты, а уже о корректности позаботится система типов языка. О подобном подходе в программировании и моделировании доменной области можно почитать много где, например, (известная статья) Designing with types: Making illegal states unrepresentable, или в книге Domain Modeling Made Functional от того же автора. На хабре есть перевод этой статьи. Еще один пример статьи - Parse, don't validate, перевод которой можно прочитать здесь. Также есть и оригинальные статьи на эту тему, такие как эта. На мой скромный взгляд, абстракты очень неплохо ложатся на эту парадигму программирования, при этом избавляют от сопутствующих проблем, вызванных слишком частым использованием классов-оберток.

Units of measure in F#

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

  • у популярного, но динамически типизируемого JS появился сводный брат TS;

  • в PHP была добавлена возможность указывать типы;

  • наш любимый Dart сменил модель типизации, что явно положительно отразилось и, возможно, стало одной из главных причин его новой жизни (кстати, если бы этого не произошло, и этой статьи бы не было).

Само собой, у динамически типизируемых языков есть свои плюсы и своя ниша, с этим никто не спорит. Но и отрицать плюсы, которые дает строгая типизация, не нужно. Нетрудно заметить, что многие новые языковые возможности и фичи в мейнстрим-языки приходят из функциональных языков, многие из которых имеют строгую типизацию. Наибанальнейшие примеры:

  • все (или почти все) мейнстрим-языки получили возможность использовать функции высших порядков, анонимные функции и замыкания (даже Java);

  • сопоставление с образцом (Pattern Matching) - исконно функциональная возможность, которая быстрыми шагами кочует в мейнстрим сегмент;

  • алгебраические типы: типы сумм и произведений (Algebraic Data TypesADT);

  • обобщенное программирование (Generics);

  • иммутабельность (неизменяемость) и иммутабельный подход в программировании в целом;

  • синтаксический сахар для монадических вычислений (async/await.?/??Null safety);

  • и многое другое...

Можно даже сделать вывод, что функциональные языки программирования являются своего рода кузницей фич, из которых мейнстрим ЯП черпают (или даже честно, не скрывая, заимствуют) вдохновение. Естественно, этот процесс не быстрый, да и сам "плагиат" носит вполне цивильный характер. Другими словами, новые фичи не просто заимствуются как есть, а "обтачиваются" под существующие реалии конкретных языков; иногда заимствованные фичи даже получаются "мощнее" оригинала. Для многих не секрет, что язык F# - это функциональный ЯП от Microsoft, на котором происходит обкатка тех или иных идей. А уже несколько позже самые полезные и интересные идеи переносятся в мейнстримный C#. Поэтому, если хотите знать с чем придется работать завтра, то можно посмотреть, что функциональные языки умеют уже сегодня.

В качестве еще одного примера давайте рассмотрим одну из интересных фич языка F#, которая буквально смогла спасти самый настоящий и жутко дорогущий космический корабль от крушения. А заодно зададимся вопросом: появится ли подобный функционал в C#?

Более подробно о случившемся инциденте можно прочитать здесь. Если вкратце, то космический корабль сгорел из-за несоответствия единиц измерения. Над этим проектом работало много команд. И вот одна из команд использовала вычисления в метрической системе в метрах, миллиметрах и килограммах. А другая - в системе империал с дюймами, футами и фунтами. Далее должно быть все плюс-минус понятно. Остается лишь один вопрос: а чем бы нам здесь мог помочь F#?

Одной из языковых возможностей F# являются единицы измерения (Units of measure). Простыми словами, код на F# поддерживает дополнительные аннотации для цифровых типов. Эти аннотации и позволяют задать единицы измерения. Пример:

[<Measure>] type m;
[<Measure>] type s;

let distance = 100.0<m>;
let time = 5.0<s>;
let speed = distance / time; // float<m/s>

С помощью метадаты [<Measure>] и ключевого слова type мы объявили две единицы измерения m - метры и s - секунда. Далее в треугольных скобках <> задаем дополнительную информацию для float типа. Таким образом мы определяем расстояние не просто как произвольное вещественное число, а вещественное число в метрах. Аналогично и время задано не просто как вещественное число, а как вещественное число в секундах. Система единиц измерений понимает, что, например, если разделить метры на время, то мы получим производный тип, в данном случае метры в секунду.

И, по аналогии с абстрактами из Haxe, аннотации в F# - не полноценные типы, а лишь дополнительная информация, которая стирается во время компиляции. Но при этом на этапе проверки типов эта информации учитывается и нельзя, например, в функцию, принимающую значение в метрах, передать значение в секундах:

let printSpeed(s: float<m/s>) = printfn $"Speed: {s} meters per second"

print(speed);

На экране мы увидим:

Speed: 20 meters per second

А вот следующий фрагмент кода уже не скомпилируется:

let printSpeed(s: float<m/s>) = printfn $"Speed: {s} meters per second"

print(distance);

Выполнение завершится с ошибкой:

[FS0001] Type mismatch. Expecting a 'float<m/s>' but given a 'float<m>'    
The unit of measure 'm/s' does not match the unit of measure 'm'

Само собой, мы не будем сильно углубляться в изучение этого функционала; там есть еще ряд возможностей. Главное для нас — это понять, как это работает (на этапе компиляции, т. е. это тоже zero cost abstraction); и как этим функционалом можно пользоваться. Мы рассмотрели несколько примеров. Нетрудно заметить, что частично абстракты из Haxe похожи на единицы измерения из F#. Конечно, это не полноценные аналоги, но некоторый похожий на единицы измерения функционал реализовать на абстрактах, думаю, можно (особенно если учитывать широкие возможности макросов в Haxe). Да и быстрый поиск в гугл это подтверждает. Подобные библиотеки даже существуют. Но насколько ими удобно и возможно пользоваться, это уже, конечно, другой вопрос.

Если подытожить, то, несмотря на простоту, абстракты — довольно мощная вещь, которая очевидно придется многим по душе. А есть ли нечто подобное в других языках?

Haskell and newtype

Язык Haxe — это относительно молодой язык, и глупо было бы предполагать, что абстракты — это новинка. Скорее всего, подобный функционал существует и в других языках. А если это так, то было бы неплохо рассмотреть, как он там устроен, какие возможности дает, и все такое. Когда речь заходит о крутых возможностях типизации, велика вероятность, что это уже было так или иначе реализовано в функциональном языке программирования Haskell. Haskell славится своими богатыми выразительными возможностями. И, конечно, в нем есть свой аналог абстрактов.

В Haskell ключевое слово newtype позволяет определить (как понятно из названия) новый тип. Фактически с помощью newtype мы определяем новый тип на основе уже существующего. Это — своего рода Wrapper — обертка. newtype, так же, как и абстракты из Haxe,‑ это тип времени компиляции. Таким образом, это тоже zero cost abstraction. Базовый тип и новый тип, объявленный с помощью newtype, будут одним и тем же типом в run‑time. Но для type checker это разные типы. Возможно, самое частое использование newtype — это использование для реализации классов типов (Type Class), что позволяет переиспользовать существующие типы для создания более высокоуровневых типов, с операциями, доступными только для этих новых типов. Из такого описания мы четко можем увидеть, что, по сути, это полноценный аналог абстрактов из Haxe. Давайте рассмотрим простейший пример:

newtype MyInt = MakeMyInt Int

addMe :: Int -> Int -> Int
addMe x y = x + y

myInt :: MyInt
myInt = MakeMyInt 12

main :: IO ()
main =  do
putStr "Sum of x + y = "
print(addMe 10 myInt)

С помощью newtype мы объявили новый тип MyInt на основе существующего типа Int и конструктора MakeMyInt. У нас есть функция addMe, которая принимает два целых числа. Но если мы попытаемся вызвать эту функцию и передать в нее значение с типом MyInt, то мы получим следующую ошибку:

error:
    • Couldn't match expected type ‘Int’ with actual type ‘MyInt’
    • In the second argument of ‘addMe’, namely ‘myInt’
      In the first argument of ‘print’, namely ‘(addMe 10 myInt)’
      In a stmt of a 'do' block: print (addMe 10 myInt)
   |
12 | print(addMe 10 myInt)
   |                ^^^^^

Что не удивительно, учитывая, что мы уже примерно знаем, как себя ведут абстракты в Haxe. Таким образом, в Haskell несложно реализовать аналогичные примеры из Haxe выше. Удостоверяться, что newtype - это именно zero cost abstraction, мы лично не будем, но если кому-то интересно, то довольно объемный и подробный разбор можно прочитать здесь.

Давайте рассмотрим чуточку более интересный и реальный пример. Предположим, мы хотим реализовать тип для натуральных чисел. И, естественно, мы не хотим "платить" за это производительностью. И в этом деле нам также может помочь newtype.

Как уже выше было написано, newtype очень часто используется в паре с классами типов (Type Class). Класс типов - это своего рода интерфейс, который описывает некоторое поведение (список функций). Если тип является частью класса типов, то это значит, что этот тип поддерживает и реализует поведение, которое класс типов описывает. Звучит, возможно, сложно, но для простоты можно считать, что классы типов - это аналог интерфейсов из ООП языков.

Давайте теперь рассмотрим еще один пример. Наша задача - сделать тип, который будет представлять собой натуральные числа:

newtype Natural = MakeNatural Integer

toNatural               :: Integer -> Natural
toNatural x | x < 0     = error "Can't create negative naturals!" 
            | otherwise = MakeNatural x
            
fromNatural             :: Natural -> Integer
fromNatural (MakeNatural i) = i

Для этого мы объявляем новый тип Natural и пару функций, позволяющих преобразовать целые числа в натуральные и обратно. А для того, чтобы нашими натуральными числами можно было пользоваться аналогично всем остальным числам в Haskell, нам необходимо реализовать класс типов Num:

instance Num Natural where
    fromInteger         = toNatural
    x + y               = toNatural (fromNatural x + fromNatural y)
    x - y               = let r = fromNatural x - fromNatural y in
                            if r < 0 then error "Unnatural subtraction"
                                     else toNatural r
    x * y               = toNatural (fromNatural x * fromNatural y)

И теперь нашими натуральными числами можно пользоваться как любыми другими. При этом мы не вводили новый реальный тип, а воспользовались newtype. Этот пример очень похож на пример из Haxe, в котором мы видели реализацию UInt поверх Int.

В математике есть такое понятие как моноид. Звучит страшно, но на деле это всего лишь ассоциативная бинарная операция над каким‑то типом данных и нейтральный элемент. Например: операция сложения целых чисел и 0, как нейтральный элемент, образуют моноид. Аналогично с умножением целых чисел, только в качестве нейтрального элемента используется единица. Другой пример: операция конкатенации над списками (и строками) и пустой список (пустая строка) так же образуют моноид. Несложно найти моноиды в операциях над логическим типом. В мейнстрим‑языках такая абстракция редко представлена явно, а вот в функциональных языках она встречается заметно чаще. В Haskell существует класс типов Monoid, который как раз и отвечает за эту абстракцию.

Теперь представим, что мы хотим реализовать класс типов моноид для сложения и умножения целых чисел. Реализовать один из этих классов типов несложно. А как быть со вторым? Ведь реализовать один и тот же интерфейс для одного и того же типа более одного раза нельзя. И тут опять на выручку приходит newtype. И — вуаля — мы можем реализовать один и тот же интерфейс для одного и того же (по крайней мере в run‑time) типа. По ссылкам можно посмотреть реализацию Sum и Product и удостовериться, что для их реализации используется newtype. И это — еще одна причина довольно частого использования newtype в Haskell. Есть мнение (не безосновательное), что newtype используется порой даже чаще, чем data — ключевое слово для объявления нового алгебраического типа данных в Haskell. Например, оказывается, что тип IO (Input/Output), который используется повсеместно в Haskell для взаимодействия с внешним миром,‑ это тоже newtype.

Другие языки...

А есть ли еще аналоги abstract и newtype в других языках программирования? Первым на ум приходит Rust. Rust, являясь молодым, активно развивающимся языком, заимствует хорошие идеи из других языков, в частности из Haskell. И неудивительно, что Rust имеет функционал, похожий на newtype из Haskell и абстракты из Haxe. Не будем подробно останавливаться на этом. Ниже небольшой пример на Rust. Обратите внимание, что если раскомментировать последнюю строку, то код не скомпилируется. Ну, это нам уже должно быть и понятно, и знакомо.

struct Years(i64);
struct Days(i64);

impl Years {
    pub fn to_days(&self) -> Days {
        Days(self.0 * 365)
    }
}

impl Days {
    /// truncates partial years
    pub fn to_years(&self) -> Years {
        Years(self.0 / 365)
    }
}

fn old_enough(age: &Years) -> bool {
    age.0 >= 18
}

fn main() {
    let age = Years(5);
    let age_days = age.to_days();
    println!("Old enough {}", old_enough(&age));
    println!("Old enough {}", old_enough(&age_days.to_years()));
    // println!("Old enough {}", old_enough(&age_days));
}

В некоторых других языках без явной поддержки абстрактов похожий функционал бывает можно реализовать на уровне библиотек. Например, существует реализация newtype для Scala. Или вот реализация для TypeScript. Как оказывается, язык Go поддерживает эту возможность нативно. Kotlin также имеет Inline Classes. В языке Scala 3 эта фича тоже присутствует. Называется она Opaque Types, основная цель этой фичи - избавиться от накладных расходов на абстракцию. Для меня оказалось приятным сюрпризом, что, в той или иной мере, довольно много условно мейнстрим‑языков поддерживает подобный функционал. Когда подобный функционал встречается в ФП языках, это воспринимается как само собой разумеющееся. Наличие инлайн‑классов в Kotlin наводит на мысли, что, чем большее влияние ФП языки имеют на мейнстрим‑язык (в данном случае Rust, Kotlin), тем быстрее интересный функционал попадает в язык.

А знаете ли вы, в каких еще языках есть нечто похожее? Было бы интересно пополнить список. Пишите в комментариях. А еще было бы очень интересно узнать, если вы пишете на языке с нативной поддержкой подобных типов, используете ли вы их в повседневной жизни? Если да, было бы интересно узнать, для каких именно целей.

Inline classes in Dart

Разобравшись с примерами и возможностями функционала для типов времени компиляции, самое время поговорить об Inline Classes. Инлайн‑классы — это аналогичный абстрактам и newtype функционал, но в языке Dart. Инлайн‑классы еще находятся в процессе реализации, но proposal принят, рано или поздно этот функционал будет реализован, и Dart расширит свои языковые и производительные возможности.

Давайте посмотрим, как примерно это все будет выглядеть:

inline class IdNumber {
  final int i;
  IdNumber(this.i);

  operator <(IdNumber other) => i < other.i;

  bool verify(Some parameters) => ...;
}

Будет добавлен новый модификатор классов inline (после Dart 3.0 нам не привыкать, судя по тенденциям, в будущем функционал может быть еще расширен). А в остальном все будет очень похоже на обычный класс с единственным полем. Пример использования:

void main() {
  int myUnsafeId = 42424242;
  myUnsafeId = myUnsafeId + 10;  

  var safeId = IdNumber(42424242);

  safeId.verify(); // OK, could be true.
  safeId + 10; // Ошибка компиляции, т.к. нет оператора сложения.
  10 + safeId; // Ошибка компиляции, некорректный тип аргумента.
  myUnsafeId = safeId; // Ошибка компиляции, некорректный тип.
  myUnsafeId = safeId as int; // OK, мы можем явно откастить.
  myUnsafeId = safeId.i; // OK, более безопасный вариант.
}

По использованию все предельно понятно и ничего нового. Обычный класс со стороны использования. Еще один пример, в котором показывается, как можно, используя инлайн-классы, реализовать zero cost abstraction для более безопасной работы с динамическими объектами:

inline class TinyJson {
  final Object it;
  TinyJson(this.it);
  Iterable<num> get leaves sync* {
    if (it is num) {
      yield it;
    } else if (it is List<dynamic>) {
      for (var element in it) {
        yield* TinyJson(element).leaves;
      }
    } else {
      throw "Unexpected object encountered in TinyJson value";
    }
  }
}

void main() {
  var tiny = TinyJson(<dynamic>[<dynamic>[1, 2], 3, <dynamic>[]]);
  print(tiny.leaves);
  tiny.add("Hello!"); // Ошибка
}

В этом примере объявлен инлайн‑класс TinyJson, который позволяет относительно безопасно работать с Json объектами. Геттер leaves дает возможность перечислить все числа из json объекта. При этом мы не сможем сделать что‑то не то, т.к. все операции задаются явно нами. Например, не получится добавить в наш json список новое значение, т.к. такая операция не объявлена в нашем классе. А вот если бы мы работали с dynamic напрямую, то избежать подобной ошибки было бы значительно сложнее. Будет вообще здорово, если с помощью инлайн‑классов можно будет описать обобщенный Json инлайн‑класс поверх dynamic, как это можно сделать в TS:

type Json = null | 
    number | 
    string |
    boolean |
    Array<Json> |
    { [key: string]: Json };

Но пока непонятно, получится это сделать или нет (скорее нет, чем да). В любом случае инлайн‑классы выглядят многообещающе. А как вы считаете?

Ну и последний пример на сегодня: использование инлайн‑классов может реализовать типобезопасное взаимодействие с JS (в частности, и с любым другим динамическим API в общем). Допустим, у нас есть ссылка на JSObject, который представляет собой UI‑кнопку. Но для работы в таком случае придется использовать небезопасные методы, такие как js_util.getProperty. Благодаря инлайн‑классам, это вполне решаемая проблема. Мы можем описать инлайн‑класс Button поверх JSObject, в котором мы опишем весь необходимый API для работы с UI кнопкой. Далее в коде основного приложения мы будем уже работать не с низкоуровневым JSObject, а с полноценным Button API, реализация которого под «капотом» и будет вызывать соответствующие низкоуровневые функции и методы, такие как js_util.getProperty.

В отличие от Haxe, команде языка Dart, на, мой взгляд, удалось дать новой фиче более понятное название (но изначально были и другие, менее интересные названия, такие как View classes, Extension struct). Кроме того, с учетом появления модификаторов классов в Dart 3.0, новый модификатор ложится в уже существующую систему, как там и был. С этим названием они тоже не первые; как выше уже отмечалось, Kotlin также имеет инлайн‑классы (возможно, команда разработки Dart вдохновлялась идеями из Kotlin?).

 There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton 

Как известно, нейминг — это сложно. И в одну из бессонных ночей я размышлял по поводу того, как бы назвать эту функциональность в общем, чтобы сразу было понятно, про что идет речь. Все‑таки, во‑первых, в том же Haxe название не самое удачное, так как сильно пересекается с общепринятым «абстрактный класс»; а, во вторых, newtype, кроме того, что это новый тип, мало что еще говорит. И в какой‑то момент в голову пришла мысль, что я бы назвал это nominally type alias. Звучит довольно по‑научному, не находите? Быстро загуглив, обнаружил, что я не первый, и такое словосочетание уже упоминалось????. Но ничего, возможно, в следующий раз повезет... А как бы вы назвали этот функционал, чтобы было понятно и единообразно (не обязательно по‑научному)?

Заключение

Ну, на сегодня все. Еще больше всего нового и интересного из мира языков программирования вообще и мира Dart и Flutter в частности — буквально в следующей статье. Спасибо за внимание. Было бы интересно узнать ваше мнение по новому функционалу языка Dart. Как вы относитесь к нему, и тому, что именно на него, в том числе, команда Dart тратит свое время? Или, возможно, стоило реализовать что‑то другое? Если да, то что? Знали ли вы (до этой статьи) о существовании подобных возможностей? Буду рад прочитать ответы на эти (да и любые другие вопросы) в комментариях. Ну, а теперь, точно, все.

Комментарии (2)


  1. ChessMax Автор
    28.06.2023 08:49
    +2

    Буквально час назад @Hokum опубликовал статью на схожую тему, только в качестве рассматриваемых языков программирования выступают Scala, Go и C++. Если вам эта тема интересна, то думаю имеет смысл ознакомиться и с этой интересной статьей.


  1. Hokum
    28.06.2023 08:49
    +1

    С удовольствием прочитал статью, отличный обзор с примерами из разных языков. Получил удовольствие от прочтения, спасибо!