Iftype Specialization¶
You sometimes want different behaviour from a generic type depending on what its type parameter turns out to be. With an iftype expression, you can branch inside a method body. With iftype specialization, you can provide an entirely separate method body that the compiler selects at compile time. There is no runtime dispatch — the compiler picks the matching body and return type and discards the rest.
A first example¶
class val Opaque
class Container[A: Any val]
var _data: A
new create(data: A) =>
_data = data
fun describe(): String =>
"opaque value"
fun describe(): String iftype A <: Stringable val =>
_data.string()
Container has two definitions of describe. The first has no guard — it is the default. The second has an iftype guard that matches when A is a subtype of Stringable val. When you create a Container[U32], U32 <: Stringable val holds and the compiler selects the specialization. When you create a Container[Opaque], the guard does not match, and the default is used.
actor Main
new create(env: Env) =>
let c1 = Container[Opaque](Opaque)
env.out.print(c1.describe()) // prints "opaque value"
let c2 = Container[U32](99)
env.out.print(c2.describe()) // prints "99"
Every method group — the set of definitions sharing the same method name — must have exactly one unguarded definition, the default. Without it, there would be no body to use when no guard matches.
Type narrowing¶
Inside a specialization, the guard is guaranteed to hold. You can call methods on values of that type that only exist on the constraining type:
class Container[A: Any val]
var _data: A
new create(data: A) =>
_data = data
fun describe(): String =>
"opaque"
fun describe(): String iftype A <: Stringable val =>
"value is: " + _data.string()
In the specialization, A is known to be a subtype of Stringable val, so calling _data.string() is valid even though the class constraint Any val does not provide string().
Multiple specializations¶
A method can have more than one specialization. The compiler checks the guards in declaration order and picks the first one that matches:
class Container[A: Any val]
var _data: A
new create(data: A) =>
_data = data
fun describe(): String =>
"unknown"
fun describe(): String iftype A <: Stringable val =>
"stringable"
fun describe(): String iftype A <: Printable val =>
"printable"
Tagged satisfies both Stringable and Printable. Because the Stringable guard appears first, Container[Tagged] uses that specialization. Order matters — put the more specific guard first.
What happens if a later guard is always subsumed by an earlier one? The compiler rejects it. If two guards on the same type parameter are ordered so that anything matching the second must already match the first, the second is unreachable and the compiler reports an error.
Method-level type parameters¶
The enclosing type does not have to be generic. A non-generic class with a generic method can use specialization on the method’s own type parameters:
class Formatter
new create() => None
fun format[A: Any val](a: A): String =>
"unknown"
fun format[A: Any val](a: A): String iftype A <: Stringable val =>
a.string()
Formatter itself has no type parameters. The method format introduces its own type parameter A, and the guard constrains that parameter. When you call f.format[U32](42), the compiler selects the specialization because U32 <: Stringable val holds.
Compound guards¶
Conjunction¶
Use and to require multiple constraints at once:
class Pair[A: Any val, B: Any val]
var _a: A
var _b: B
new create(a: A, b: B) =>
_a = a
_b = b
fun describe(): String =>
"generic pair"
fun describe(): String
iftype A <: Stringable val and B <: Stringable val
=>
_a.string() + ", " + _b.string()
The specialization matches only when both A and B are subtypes of Stringable val. If either one is not, the default is used. Unlike or guards, an and guard can constrain different type parameters.
Disjunction¶
Use or when any one of several constraints is enough:
class Container[A: Any val]
var _data: A
new create(data: A) =>
_data = data
fun describe(): String =>
"opaque"
fun describe(): String
iftype A <: Stringable val or A <: Displayable val
=>
"has a text representation"
The specialization is selected when A is a subtype of Stringable val or a subtype of Displayable val. Every clause in an or guard must name the same type parameter on the left side of <: — you cannot write A <: X or B <: Y.
Can I mix and and or in a single guard? No. A guard is either all and or all or. If you need a more complex condition, use multiple specializations.
Constructors and behaviours¶
Specialization works for all three method kinds: functions, constructors, and behaviours. Here is a constructor example:
class Container[A: Any val]
var _data: A
var _label: String
new create(data: A) =>
_data = data
_label = "opaque"
new create(data: A) iftype A <: Stringable val =>
_data = data
_label = _data.string()
fun label(): String => _label
The same rules apply. Each constructor specialization must initialize every field, just as any constructor must. Behaviour specializations follow the same pattern — add an iftype guard after the return type on a be declaration.
Partial methods¶
A specialization can be partial only when the default is partial. The ? goes after the iftype guard:
class Container[A: Any val]
var _data: A
new create(data: A) =>
_data = data
fun get(): String ? =>
error
fun get(): String iftype A <: Stringable val ? =>
_data.string()
The caller compiles against the default’s signature. A non-partial default promises the caller that no call can error, so a partial specialization would break that promise.
Traits and interfaces¶
Traits can declare a method group — a default definition and one or more specializations — and concrete types inherit the entire group.
Inheriting a method group¶
trait Describable[A: Any val]
fun describe(): String =>
"unknown"
fun describe(): String iftype A <: Stringable val =>
"stringable"
class Container[A: Any val] is Describable[A]
var _data: A
new create(data: A) =>
_data = data
actor Main
new create(env: Env) =>
let c = Container[U32](42)
env.out.print(c.describe()) // prints "stringable"
Container provides Describable[A] and defines no describe of its own, so it inherits both the default and the specialization from the trait. When A is U32, the compiler selects the specialization.
Trait chains¶
A class that provides a trait through an intermediate trait inherits the full method group:
trait Base[A: Any val]
fun describe(): String => "base"
fun describe(): String iftype A <: Stringable val => "stringable"
trait Middle[A: Any val] is Base[A]
class Impl[A: Any val] is Middle[A]
var _a: A
Impl inherits the method group from Base through Middle, even though Middle adds nothing of its own.
Overriding a method group¶
A concrete type can provide its own definitions, replacing the trait’s:
trait Describable[A: Any val]
fun describe(): String =>
"unknown"
fun describe(): String iftype A <: Stringable val =>
"stringable"
class Container[A: Any val] is Describable[A]
var _data: A
new create(data: A) =>
_data = data
fun describe(): String =>
"container default"
fun describe(): String iftype A <: Stringable val =>
"container: " + _data.string()
actor Main
new create(env: Env) =>
let c = Container[U32](42)
env.out.print(c.describe()) // prints "container: 42"
Container overrides both the default and the specialization. You can override any combination — just the default, just a specialization, or the entire group. Any definition you do not override is inherited from the trait unchanged.
Interfaces¶
Interfaces can declare method groups too. An interface specialization has no body — it declares the signature that a conforming type must provide:
interface Describable[A: Any val]
fun describe(): String
fun describe(): String iftype A <: Stringable val
A concrete type that provides Describable must have both a default describe and a matching specialization. Since interfaces use structural subtyping, the compiler checks that any type with the right shape matches the requirement, whether or not it names Describable in its is clause.
Rules¶
Specializations are subject to several constraints:
- The left side of
<:in a guard must be a type parameter (or a tuple of type parameters). You cannot use concrete types. - The default and all specializations must have the same parameter types, the same receiver capability, the same method kind (
fun,be, ornew), and the same method-level type parameters. - The return type of a specialization must be a subtype of the default’s return type. When the type arguments are concrete and a guard matches, the caller sees the specialization’s return type.
- A specialization can be partial (
?) only if the default is also partial. - If a later guard’s constraint is subsumed by an earlier guard on the same type parameter, the later guard is unreachable and the compiler rejects it.
Iftype specialization vs. iftype expressions¶
Pony uses iftype in two different places. An iftype expression is a control structure inside a method body — it branches on a type parameter at compile time, much like if branches on a value at runtime. Iftype specialization puts the guard on the method declaration itself, giving you a separate method body and return type for each case.
The two can be used together. A specialization provides a separate body; an iftype expression within that body can branch further. When each branch needs a completely different implementation, specialization keeps the bodies separate and applies type narrowing to the whole body rather than just one branch of an expression.