Writing a Simple User Defined Type System in Kotlin — Part 2.5: Advanced Type Features
The Type system we defined is powerful and flexible. Unlike some UDT systems, it allows a type to be defined in code as easily as from user data, and to be used almost like a Kotlin type, as long as all you want to do is move data around. So we’re going to add some more features.
First, it would be nice to more easily access, from code, the properties of a UDT. It’s possible with the data class we’ve defined, but you’d have to iterate the list of properties, we’ll fix that with a similar utility to the one we used in the
DType for its fields. As well as a map indexing the values, we’ve added a generic get method to the class, so we can get a typed value concisely.
We can define composite types with our system, but we have no concept of inheritance common to most class based type systems. This is not a problem in of itself, but it would be great if we could guarantee some fields, e.g. if a user could define the type, but we could ensure that certain fields always existed. For example, if we’re saving instances to a database, it would be great if we could ensure there is always an ID field. UDTs I’ve worked on in the past often have reserved identifiers, and hard code the ID field in, but we’re going to use our type system to build it.
Looking at our current type system, all a type provides is an identifier, name, and a list of fields. If we were to “subclass” a type, the only part we really want to keep is the fields, the new class will define the name and identifier.
We could take a
DType and make a subclass method to add fields, a new name and a new identifier, but it is more usual to see that a Subclass extends a Class. Kotlin allows us to define new infix functions. Unfortunately we can’t define a
:operator, nor can we use varargs in infix operators, but we can overload the function we define to take a single supertype, or a list. We can also define the function as an extension, allowing us to break it out into its own file so that the core type system file doesn’t grow any larger (this is a matter of preference, the core file is already larger than I usually allow source files to grow, but I think it’s important to keep all of the
Value core code together). We get something like this:
We can now extend a class like so
Creating a new pupil will accept positional arguments in the order specified first by the superclass, then by the subclass (or you can use kwargs to be explicit). So in this case we’ll create a pupil with arguments id, first name, last name, date of birth.
Looking at this code revealed a subtle bug that is present in the type system, but more obvious when you start inheriting classes: what happens if the same field is defined by multiple ancestors. Or even, if the field is defined multiple times in one
DType block. Since we don’t reject it, we’ll create multiple properties in the same instance, which could, depending on if you use args or kwargs, match either definition of the identifier. We should prevent that. We’re also not terrible people, so we’re going to try to provide a useful error message about where the conflict occurs.
DType, we add an
init block to detect multiple definitions of the same field. We also rework the extends functions to detect and disallow clashes/overrides (we could allow specifying the same field twice if it was identical, but even that would make the invoke method ambiguous, so we’ve chosen to disallow it). Finally, we’ve rewritten the multiple superclass version of the extends function to work from right to left, and repeatedly call extends. This would be equivalent to
A extends (B extends C) and will error the first time a clash is encountered. We could spend the time to make it point out every clash, but that’s overkill for what we expect to be fairly shallow inheritance hierarchies. (Incidentally, this is a very similar mechanism to Python’s multiple inheritance resolution)
We can now specify inheritance, which is great for hybrid User/System defined types (e.g. entities). It would be nice to have the counterpart to that: detecting if a
DTypeValue is an instance of a
DType. We have a few options for how to handle this.
We could approach this in a dynamic way, using the
DTypeValue#get method and catching errors if the instance doesn’t have the field we try to access. Alternatively, we could test the type upfront. We’re going to take the second option, but making use of Kotlin’s features to make it a little more flexible.
The only thing we care about when testing types is if a particular set of fields exists on the instance. As fields are compared by the values of their properties (being data classes) the following implementation can check if the current value implements an interface:
That’s all there is to testing an implementation. We can now add
DTypeValue extension methods to imitate methods on the
DType. e.g. Adding a
fullName method to the pupil type:
This can be cleaned up a bit more with a higher order function
There are various other ways to achieve this — we could implement a
DTypeValue#send method similar to Ruby, define functions on the
DType which could be called by name on the value — they’d have to accept the value as the first parameter, making them similar to Python, or we could even add function tables to instances like Lua does. But at that point we’ve diverged from the User-Defined Type system into a full, dynamic type system.
This concludes this section of the guide. We’ve moved from a User Defined Type that is just a structure for holding data, to one that can be a hybrid of system and user types, and can have methods defined on it. It resembles a dynamic/scripting language because all variables are of Type
DTypeValue and checking the
DType happens at runtime — but since the type is defined from a user it has to be a runtime check — the system exists in a world where the types only exist, and can change, during runtime. In building it, we’ve also seen a glimps behind the curtain at why dynamic languages such as Ruby, Lua and Python operate the way they do. In building our own system, even without looking to them for inspiration, our options have started to converge on the methods they use.