Value Types
This guide describes a feature of Rel that allows you to define your own types.
Introduction
There are two kinds of types that you can define yourself: entity types and value types. A value type has its own name and is distinct from every other type, even though its values are represented by tuples of primitive types. This allows you to make your model more clear and readable. It also helps you to avoid some common mistakes.
A Simple Example
You have built the first small fragment of a toy railway, and you intend to expand it significantly. You decide to build a computer model of the rail network, so that you can answer questions about transit times, distances, and the like. This will come in handy when the network grows.
There are three stations: “A”, “B”, and “C”. You know the distances between them, and how much time it takes to travel from one to the other:
// model
def distance = (:A, :B, 100.0)
def distance = (:B, :C, 50.0)
def travel_time = (:A, :B, 30)
def travel_time = (:B, :C, 20)
This captures your data, but the model has a problem. Since the distances and time intervals are represented by numbers, it is all too easy to get confused and perform operations that should not be allowed. For instance, you can add a value that represents a distance to a value that represents a time interval:
// read query
def output[x, y] = distance[x, y] + travel_time[x, y]
This doesn’t make much sense, and you would like to be protected from such mistakes. In other words, you would like a number that represents a distance to be clearly distinguishable from a number that represents a time interval. Rel allows you to do that by declaring value types.
Declaring Simple Value Types
The following declaration introduces a new type, Distance
, whose values are represented by floating-point numbers but are distinct from values of all other types.
value type Distance = Float
The declaration also introduces an infinite binary relation, ^Distance
, which is often referred to as the constructor of Distance
.
The relation ^Distance
is a one-to-one map between floating-point numbers and the corresponding values of type Distance
.
It can be used both to construct value types (^Distance[100]
) and to retrieve the actual value(s) from it (x: ^Distance(x, ^Distance[100])
).
Note that there is nothing special about identifiers that begin with the character ^
, but in the interest of readability it is best to use them only for constructors of value types and of entity types.
Starting from scratch, you rewrite the model:
// model
value type Distance = Float
value type Duration = Int
def distance = (:A, :B, ^Distance[100.0])
def distance = (:B, :C, ^Distance[50.0])
def travel_time = (:A, :B, ^Duration[30])
def travel_time = (:B, :C, ^Duration[20])
The square brackets are important.
The partial application ^Distance[100.0]
evaluates to the value of type Distance
that corresponds to the number 100.0
.
The first definition of distance
above could also have been written as def distance = (:A, :B, {d : ^Distance(100.0, d)})
.
It may be instructive to display the relation distance
:
// read query
def output = distance
The heading of the third column indicates that the column shows floating-point representations of the type Distance
, which is shown as the meta value :Distance
.
For good measure, here is the relevant part of the implicit relation ^Distance
.
// read query
def output(x, y) = ^Distance(x, y) and distance(_, _, y)
The problematic addition of distances to time intervals no longer works.
If you try it, the output will be empty, just as it would be for def output = 5 + "five"
:
// read query
def output[x, y] = distance[x, y] + travel_time[x, y]
However, not all is well yet.
It would be nice to add a distance to another distance, but addition is not defined for values of type Distance
:
// read query
def output = distance[:A, :B] + distance[:B, :C]
Currently such operations on value types must be defined by the user, as shown in the following subsection. Automatic support will be provided in the future.
Extracting the Representation of a Value (Simple Case)
To define addition for two values of type Distance
, you must extract the associated floating-point numbers from ^Distance
.
This can be done as follows:
// model
def distance_to_float(d in Distance, x) = ^Distance(x, d)
You could achieve exactly the same effect by using the Standard Library relation transpose:
def distance_to_float = transpose[^Distance]
You can now list the distances as floating-point values, or add such floating-point values together:
// read query
def output[source, destination] =
distance_to_float[distance[source, destination]]
// read query
def output =
distance_to_float[distance[:A, :B]] + distance_to_float[distance[:B, :C]]
Everything is now in place to compute the average speed of the train between neighboring stations:
// model
def duration_to_int = transpose[^Duration]
def speed[a, b] =
distance_to_float[distance[a, b]] / duration_to_int[travel_time[a, b]]
// read query
def output = speed
You might also want to define appropriate operations for your value types, for example, addition of distances:
// model
def (+)[x in Distance, y in Distance] =
^Distance[distance_to_float[x] + distance_to_float[y]]
// read query
def output = distance[:A, :B] + distance[:B, :C]
Full Generality
In general, a value type can be represented by a tuple. For example, you might want to associate a point in three-dimensional space with its three Cartesian coordinates. You can do this as follows:
// model
value type Point = Float, Float, Float
def point = (:P1, ^Point[0.0, 1.0, 2.0])
def point = (:P2, ^Point[-3.0, -2.0, -1.0])
The relation ^Point
is not binary:
// read query
def output(x, y, z, v) = ^Point(x, y, z, v) and point(_, v)
Extracting the Representation of a Value (General Case)
Since ^Point
is not binary, you cannot use transpose
to make it easy to get the three floating-point numbers that represent a value of type Point
.
However, you can define a relation that moves the last element of each tuple to the first position.
The definition uses varargs:
// model
@inline
def rotate_right[R](x, y...) = R(y..., x)
You might even want to use a convenient wrapper: functions that convert a value of type Point
to its coordinates.
The code looks like this:
// model
@inline
def coordinates[p in Point] = rotate_right[^Point][p]
@inline
def coord1[p in Point] = first[coordinates[p]]
@inline
def coord2[p in Point] = second[coordinates[p]]
@inline
def coord3[p in Point] = last[coordinates[p]]
It is now easy to get the coordinates of points or to compute the distance between two points:
// read query
def output = label, coordinates[p] from label, p where point(label, p)
// model
def distance[x in Point, y in Point] =
sqrt[squared[coord1[x] - coord2[x]] +
squared[coord2[x] - coord2[y]] +
squared[coord3[x] - coord3[y]]]
// read query
def output = distance[point[:P1], point[:P2]]
Units of Measurement
The general form of value-types allows you to associate quantities with units of measurement. For example, in the little application for toy trains it was implicitly assumed that distances were expressed in terms of some unit of distance, but that unit was not explicitly specified. The unit can become a part of the value type.
You can try it out without overwriting the previous definitions by using different names:
// model
def UnitOfDistance = :in ; :cm ; :km ; :mile
value type UDistance = UnitOfDistance, Float
def dist = (:A, :B, ^UDistance[:cm, 100.0])
def dist = (:B, :C, ^UDistance[:in, 50.0])
The results of displaying dist
may seem surprising at first:
// read query
def output = dist
Why is the type “Mixed”? Things become more clear after displaying each of the distances separately:
// read query
def output = dist[:A, :B]
// read query
def output = dist[:B, :C]
The unit is a part of the type. It is worth knowing that the information about the unit is a compile-time artifact and is not stored in the database. The extra precision comes for free!
Distances in different units are not directly comparable:
// read query
def output {^UDistance[:cm, 100.0] = ^UDistance[:cm, 100.0]}
// read query
def output {^UDistance[:cm, 100.0] = ^UDistance[:in, 100.0]}
// read query
def output {^UDistance[:cm, 254.0] = ^UDistance[:in, 100.0]}
Although the unit field in a value of type UDistance
is not stored explicitly, ^UDistance
is a ternary relation.
So the following works:
// model
def unit[d in UDistance] = first[rotate_right[^UDistance][d]]
def quantity[d in UDistance] = second[rotate_right[^UDistance][d]]
// read query
def x = ^UDistance[:in, 50.0]
def output = unit[x]
// read query
def x = ^UDistance[:in, 50.0]
def output = quantity[x]
The Right-hand Side Specifies a Relation
The general form of a value type declaration is quite similar to the definition of a relation. The right-hand side of the declaration specifies an arbitrary relation that is the domain of the constructor.
Up to now, you have only seen domains that are type relations or products of type relations. But the mechanism is general.
For example:
// read query
def P = (1, 2); (3, 4); (3, 5)
def Q = "ab"; "cd"
value type V = P; Q
def output:A = ^V[1, 2]
def output:B = ^V["cd"]
def output:C = ^V["bc"]
def output:D = ^V[3]
def output:E = ^V[4]
Notice that ^V["bc"]
evaluates to false
, because the string "bc"
is not in the domain P; Q
.
Similarly, there is no tuple in the domain that begins with 4
, so there is no result for ^V[4]
.
The example above is not one to follow, but an illustration of the generality of value type declarations. Being aware of the generality may help you understand the unwanted effects of inadvertent mistakes.
Here is another example of this generality.
You may want to have a value type Small
that is represented by a pair consisting of a small integer and a short string.
An attempt to construct a value of this type with a larger integer or a longer string will be unsuccessful, but will not elicit a warning from Rel.
Here is a way to write this:
// read query
value type Small(x in Int, y in String) { abs[x] < 4 and string_length[y] < 4 }
def output = ^Small[0, "abc"]; ^Small[5, "a"]; ^Small[3, "long"]; ^Small[3, "b"]
Multiple Definitions of the Same Value Type
A value type can have multiple definitions. For example, you might want to create points from integers as well as from floats:
// read query
value type Point = Float, Float, Float
value type Point = Int, Int, Int
def switch[a in Point, b in Point] = b, a
def output = switch[^Point[1.0, 2.0, 3.0], ^Point[1, 2, 3]]
In the example above, the relation switch
can have arguments of either kind of Point
.
But points created from floats will be different from those created from integers.
For example, given the definitions above, the following will evaluate to false
:
def output { ^Point[1.0, 2.0, 3.0] = ^Point[1, 2, 3] }
Declaring Value Types in Terms of Value Types
Value types are fully fledged types. In particular, value types can be used to define other value types.
However, the definitions cannot be directly or indirectly recursive.
Consider the following example:
// read query
value type A = Int
value type B = String
value type C = A, B
def output = ^C[^A[1], ^B["bb"]]
Compare the above with the following:
// read query
value type C = Int, String
def output = ^C[1, "bb"]
Even if it doesn’t seem so from the outputs, the representation of tuples is identical in both examples. In other words, you can use value types without incurring additional storage costs.
Definitions of Value Types Cannot Be Recursive
Section Multiple Definitions of the Same Value Type contained an example of two definitions of the value type Point
.
The second definition made it possible to create points from integer coordinates, but such points were different from points that were created from floating-point coordinates.
It is tempting to use a trick similar to the one that is common in the programming language Julia, and make the second definition perform a conversion to the canonical representation, that is, a triple of floating-point values:
value type Point = Float, Float, Float
value type Point(x in Int, y in Int, z in Int) {
Point[int_float_convert(x), int_float_convert(y), int_float_convert(z)]
}
This will not work, because Rel does not allow the definition of a value type to be recursive.
In general, allowing recursive definitions of value types would enable you to write, for example, the following definitions for a type that is intended to emulate linked lists of integers:
value type Nil
value type Cons = Int, Nil
value type Cons = Int, Cons
If this were legal, you would be able to construct lists of arbitrary length, for example:
def output = ^Cons[ 2, ^Cons[1, ^Nil[]]]
This cannot be allowed, because a value of a value type must have a fixed length that can be determined at compile time. Values whose lengths are not fixed could not be stored as elements of tuples.
Empty Value Types
A value type need not have any representation.
The declaration value type None
is correct; the corresponding constructor relation, ^None
, is empty.
The effect is similar to that of type Missing
(opens in a new tab) in Julia.
The non-existing value can be used as an element in a tuple, but yields no result when applied in arithmetic operations:
// read query
value type None
def none = ^None[]
def output:tuple = 1, none, "three"
def output:mult = none * 1
In fact, Rel already has a built-in type that is somewhat similar.
As in Julia, it’s called Missing
, and the nonexistent value has the name missing
.
// read query
def output:tuple = 1, missing, "three"
def output:mult = missing * 1
The type Missing
is currently not a value type and the relation ^Missing
does not exist.
Value Types in Modules
A value type defined in a module can be used as expected, with one exception that is described at the end of this section.
If a module is not parameterized, the value type can be declared in the module, used inside the module, and imported from the module. All these features can be illustrated by a simple example:
// read query
module A
value type V = Int
def v = ^V[7]
end
def output = A:v
def output = A:^V[8]
with A use ^V
def output = ^V[17]
If a module is parameterized, a value type can be declared in terms of the parameter, and can be imported from the module:
// read query
@inline
module A[R]
value type V = R
end
def output:one = A[Int][:^V][3]
def output:two = A[String][:^V]["hi!"]
However, if you use the value type within the module, then you must replace @inline
with @outline
:
// read query
@outline // NOT @inline!
module A[R]
value type V = R
def v = ^V[top[2,R][_]]
end
def p = 1; 3; 5
def output:one = A[p][:v]
def output:two = A["A"; "B"; "C"][:v]
@outline
must be used even if the value type does not depend on the parameter:
// read query
@outline // NOT @inline
module A[R]
value type V = Int
def v = ^V[7]
end
def output = A[String][:v]
Summary
In brief, value types allow you to distinguish between different kinds of values, even if they have the same underlying representation. This feature helps in avoiding some mistakes and making models easier to read.
Value types can be used to define other value types, as long as such definitions are not directly or indirectly recursive.