Trait-Oriented Programming in Wipple
Wipple
I’ve come up with a paradigm called “trait-oriented programming”. After experimenting with it for a while I think it’s the most natural way to think about data, and it fits Wipple perfectly! So how does it work?
Pillars of Trait-Oriented Programming
- Traits uniquely describe how a value can be used in the program.
- Values are simply collections of traits.
- Conformances describe how to derive a trait from a value if a condition is met.
In Wipple, traits are represented by values, meaning that values are just collections of other values. (Eventually, of course, there must be a few intrinsic traits that are actually represented differently by the interpreter.) The important thing to understand here is that values do not have a single representation in TOP — instead, traits are used to describe all representations of a value. For example, the number 42 can be represented as an integer, a decimal, a string, a DOM node, etc. All of these representations are equally valid depending on the context, and you can freely switch between them. Given this, there are a few rules regarding the use of traits:
- Use traits to describe lossless, equivalent representations of a value; eg. a numeric representation and a string representation can be converted between each other without losing any information.
- Traits that describe lossy representations of a value describe the value’s behavior, and should be used in contexts when the original value is not important.
- Traits must be pure (ie. no mutation, IO or other side effects) because it doesn’t matter where/how they are used.
The rule of using traits to describe lossless, equivalent representations of a value ensures that Wipple remains a “safe” language in regards to datatypes — unlike JavaScript or other loosely-typed languages, nothing is being coerced from one type to another.
Because defining every single trait on every single value would be tedious and inflexible, conformances allow you to describe a new representation or behavior of an existing value, assuming it meets certain conditions. When a trait from a value is used that isn’t directly defined on the value, it is automatically derived as implemented by the conformance. This means that, in general, you can explicitly define one trait of a value (eg. during the value’s creation) and use it as if any of the traits were defined explicitly.
Some code examples
Basic traits
Let’s consider a trait called Text
that stores a string representation of a value. If we have some
value x
, we can give it a string representation by explicitly defining Text
’s value:
x :: Text "the amazing x value"
Remember that we aren’t reassigning x
here — we’re just giving it another representation (:
is
for assignment, ::
is for traits). Now we can define a function, print
, that uses the Text
value:
print : Text value -> ...
print x
In the context of the function, value
will refer to "the amazing x value"
when we pass in x
.
Another important thing to understand is that "the amazing x value"
is also a value with its own
traits. In particular, "the amazing x value"
has the Text
trait, which contains itself.
Conformances
Say we have a Color
trait that stores an RGB color representation of a value, and say we have
red
, orange
, yellow
defined as values containing the Color
trait. Now if we wanted to make
these colors (read: values with a color representation) applicable to print
, we could manually add
the Text
trait to each of them. The problem, though, is that there may be many more colors, and
possibly new ones defined in the future. To solve this, we can define a conformance:
Color color ::= Text ...
This conformance says that all values containing the trait Color
can also be represented by the
trait Text
(the ...
is the omitted implementation, which returns a string value and has access
to color
). Now any function accepting a Text
value can also accept a Color
value, with no
modification to the function’s implementation!
print red -- works!
green : <some color>
print green -- also works!
Keep in mind that conformances can accept any condition — for example, we can say that a list of
Text
values is also Text
:
(List and (each Text)) list ::= Text ...
We can also use this approach to create “generic” functions:
-- given a trait Add:
add : Add a -> a
How does this work? Remember that functions accept the value of the trait, not the original
value passed into the function. This means that add
simply delegates to the implementation
provided by the Add
trait of the input. This pattern is called “implementing” a trait, and is more
generically defined by the impl
function:
impl : Trait T -> T x -> x
Say we have some value foo
and a trait Bar
:
foo :: Bar (x -> do-something-with x)
Then we can call impl Bar
on foo
like this:
do-something-using-foo : impl Bar foo
foo 42 -- result of 'do-something-with 42'
The transitive property
Wipple does not automatically apply the transitive property to conformances; eg. this code will not work:
A x ::= B x
B x ::= C x
x :: A _
C for x
You need to explicitly conform A
to C
using B
as the implementation:
A x ::= C (B for x)
The reason for this is twofold — first, it would be extremely expensive to calculate all possible paths considering the conformance condition can be any validator, and second it causes ambiguity when a value has multiple traits that can be used to derive a conformance through the transitive property.
Interacting with traits in Wipple
Operator | Description | Usage |
---|---|---|
: | Assign a value to a name | x : 42 |
:: | Add a trait to a value | x :: Text "the amazing x value" |
::= | Define a conformance | (List and (each Text)) list ::= Text ... |
is | Perform a validation | when (x is Number) ... |
trait | Create a new, unique trait that validates its value | Color : trait (shape (r Number g Number b Number)) Foo : trait _ |
_ | The value containing no traits directly; can be used as a validator that accepts all values | _ |
T for x | Get the trait value from a value | Number for x |
impl | Create a macro that delegates to the trait value | add : impl Add |
new | Create a value containing the provided trait | red : new Color (r 255 g 0 b 0) |
of | Get the validator of a trait’s associated value | Text = of Text |
-> | Create a function | inc : x -> x + 1 inc : Number x -> x + 1 |
=> | Create a macro | inc! : x => (x : x + 1) |
How does new
work?
new : Trait T -> value -> {
x : _
x :: T value
x
}
How does x is T
work?
Trait values (the container holding the actual trait so it can be referred to by name) conform to
the Validate
trait, which the is
operator uses to validate the input. If the input contains the
trait, or the trait can be derived via a conformance, the validation succeeds.
This was a very technical explanation of traits, but I will be publishing more accessible examples soon, as well as a comparison between TOP and other paradigms — stay tuned!