Abstract Types

Introduction

Haxe 3 introduces the concept of abstract types, which come in two flavors:

  • abstract value types like Void,Int and Float that have no implementation
  • opaque abstract types which adds functionality to another type

These are some examples from the haxe standard library:

abstract Void { } // value type with no relations
abstract Int to Float { } // value type which implicit casts to Float
abstract UInt to Int from Int { } // value types which auto-casts to and from Int
abstract EnumFlags<T:EnumValue>(Int) { ... } // opaque type based on Int
abstract Vector<T>(VectorData<T>) { ... } // opaque type based on VectorData<T>

What we can learn from these examples includes that abstract types

  • are declared with the abstract keyword, followed by a name,
  • can define compatible implicit cast types through from Type and to Type syntax,
  • may declare an underlying type through (Type) syntax,
  • may have type parameters and
  • may provide an implementation consisting of class fields.

Implicit cast

Abstract types allow the definition of "target" (to) and "source" (from) types. Consider the following example

@:coreType abstract Kilometer from Int { }
class Main {
    static function main() {
        var km:Kilometer = 1;
        var n:Int = km; // Kilometer should be Int
    }
}

We declare an abstract type named Kilometer to have a from type Int. This allows the assignment of integer 1 to variable km, but does not allow the next line. If we wanted that, we would have to add to Int to the abstract declaration.

Let us do that, but let us also change the type of variable n to Float:

@:coreType abstract Kilometer from Int to Int { }
class Main {
    static function main() {
        var km:Kilometer = 1;
        var n:Float = km;
    }
}

This shows another feature of abstract value types, which is transitive casts: While Kilometer could not be assigned to Float directly because it has no to Float declaration, it can be cast to Int, and Int can be cast to Float.

Opaque types

More interesting than pure value types are opaque types, which adds functionality to another type. Here is an example for such a declaration:

abstract StringSplitter(Array<String>) {
    inline function new(a:Array<String>)
        this = a;
        
    @:from static public inline function fromString(s:String) {
        return new StringSplitter(s.split(""));
    }
}

Several things are going on here:

  • The abstract type StringSplitter is declared to have an underlying type Array<String>.
  • It has a private constructor, which takes an Array<String> argument and initializes this to that.
  • It comes with a static @:from function, which seems to take a String and return a StringSplitter.

It might be unusual to see a this = a assignment, but it makes sense: Opaque abstract types adds its functions to another type and can modify that type's value by accessing this. The type of this is always the underlying type of the abstract, which means Array<String> in above example.

With the constructor being private, it's common to have a static function which initializes the type. This is a known design pattern from classes, and applies to abstracts as well. The static function fromString obviously takes a String argument and initializes a StringSplitter object with the String split into pieces.

Even though it may look like so from the construction, opaque abstract types aren't wrapping the underlying type. The functions of the abstract type are compiled into each underlying type (hence the name abstract, it doesn't exist at runtime), so there are no problems with object schizophrenia or naming collisions that can happen for traits or extension methods, which are similar features in other languages.

So what does @:from do?

It defines a cast-function, that is it describes how to convert any given object to an object of the abstract type. It then also enables implicit casts:

class Main {
    static function main() {
        var splitter:StringSplitter = "Hello";
        trace(splitter); // [H,e,l,l,o]
    }
}

At this point it helps to look at the generated code to understand what is going on. This can be done using the -D dump=pretty parameter:

haxe -main Main -D dump=pretty

 // dump/Main.dump
class Main{
    static main(method) : Void -> Void

     = function() = {
        var splitter = cast "Hello".split("");
        haxe.Log.trace(splitter,{fileName : "Main.hx",lineNumber : 13,className : "Main",methodName : "main"});
    };

}

The assignment of "Hello" has been replaced by a direct call to "Hello".split("") because both fromString and the constructor are inlined.

There's also a @:to, right?

Yes, and it's completely parallel:

abstract MyString<T>(String) {
    public inline function new()
        this = ""
        
    public inline function append(s:String)
        this += s
        
    @:to public inline function toArray():Array<String>
        return this.split("")
}

class Main {
    static function main() {
        var s = new MyString();
        s.append("foo");
        s.append("bar");
        printArrayString(s);
    }
    
    static function printArrayString(a:Array<String>)
        trace(a.join(""))
}

It works because printArrayString expects an argument of type Array<String>, and MyString happens to have a @:to function toArray():Array<String>.

Operator overloading

Abstract types allow operator overloading for binary and unary operators:

abstract MyInt(Int) from Int to Int {
    // MyInt + MyInt can be used as is, and returns a MyInt
    @:op(A + B) static public function add(lhs:MyInt, rhs:MyInt):MyInt;

    @:commutative @:op(A * B) static public function repeat(lhs:MyInt, rhs:String):String {
        var s:StringBuf = new StringBuf();
        for (i in 0...lhs)
            s.add(rhs);
        return s.toString();
    }
}
  • Metadata @:op(operation expression) is used to define the kind of operator to overload
  • add in this example defines an operator without function body. This can be used if the underlying type supports the operation, which is the case for Int.
  • repeat defines the operation * between MyInt and String. It is set to @:commutative, so String * MyInt works as well.

Array access overloading

abstract MyReflect({}) from {} {
    @:arrayAccess public inline function arrayAccess(key:String):Dynamic {
        return Reflect.field(this, key);
    }
    
    @:arrayAccess public inline function arrayWrite<T>(key:String, value:T):T {
        Reflect.setField(this, key, value);
        return value;
    }
}

Using the @:arrayAccess metadata, an abstract is marked as allowing:

  • read-access if the function accepts one argument
  • write-access if the function accepts two arguments

version #19817, modified 2013-10-25 01:41:51 by tong