Puppet 6 Type System - Object Inheritance
Introduction
This is the third posting in the series about the Object data type in the Puppet Type System - Pcore. The first post introduced the Object data type and the history behind Pcore. You probably want to read that first. The second post covers more about how attributes are specified. In this post, I will cover inheritance as well as one feature I forgot in the second post.
Constant Attributes
Yeah, so, I forgot to mention that there is a short form for specifying constants. In the second post I showed that a constant can be defined like this:
attributes => {
avg_life_expectancy => {
type => Float,
kind => constant,
value => 70.5,
}
}
Which is a bit much when really all that is needed is the name and the value (since type can be inferred from the value). For that reason there is constants
that work the same way as attributes
, but the short form expects a value rather than a type.
This means that the above example can be written:
constants => {
avg_life_expectancy => 70.5
}
Ok - with that bit out of the way, moving on to the main attraction of this post… Inheritance.
Object Inheritance
The Puppet Type system supports classic OO inheritance such that an object type can inherit the definition of exactly one parent object type (which in turn can inherit, and so on). None of the OO concepts “interface”, “multiple inheritance”, or “extension” exists in Pcore, although an Object definition is in fact an interface from which an implementation is generated or for which an implementation is registered at runtime.
Notably, it is only possible to inherit from data types that are in turn some kind of Object. It is for example not possible to inherit from Integer or String - for those it is possible to instead create a type alias - but it is not possible to create new kinds of integers or strings.
Here are examples of using type aliases:
type PositiveInteger = Integer[0, default]
type Age = PositiveInteger
Specifying Inheritance
To make an Object type inherit another its parent type is
specified. The parent type must be an Object type:
type Vehicle = {
attributes => {
manufacturer => String,
}
}
type Car = {
parent => Vehicle,
attributes => {
reg_nbr => String,
}
}
Creating Instances
When creating an instance, it is possible to either specify arguments by position or giving them as a hash. This is the same
as when there is no parent type specified, but when there is a parent, all of the parent’s attributes must be given before the attributes of the type. This also applies if there is a longer inheritance chain.
Giving attributes by position - ancestors in order:
notice(Car('Lamborghini', 'XYZ 666'))
Giving attributes as a hash - order does not matter:
notice(Car(
reg_nbr =>'XYZ 666',
manufacturer => 'Lamborghini'
))
As you have probably already figured out, using “arguments by
position” is really useful when there is just one or two arguments, but becomes difficult to read and maintain when there are multiple
and attributes. Also, giving arguments in hash form is required when a parent type has optional arguments.
Using ‘override’
A type may override inherited attributes (including constants) and operations (to be described in a separate post). In order to override something there are a number of rules regarding how and with what something can be overridden. These rules are in place to assert that the contract put in place by a parent type is not violated by a child type - for example say we have a Vehicle
type with an attribute weight => Integer[0, default]
, then it would not be good if some child type changed that to be a String
since that would mean that not all kinds of vehicle have the same contract!
When specifying override => true
for an attribute the attribute it is overriding must exist in a parent. This is a code safety thing as logic could otherwise think it is overriding something but instead ending up adding a new attribute that probably does not get read or used as intended. Likewise, if override
is not set for an attribute it is an error if this attribute already exists in a parent. This is also about code safety as the author of a parent type may not be aware of your subtype, and likewise you may not be aware of new attributes added in a later version. In summary, these rules protected code from having potential problems with both accidental and non effective overrides.
When overriding all properties of the attribute must be specified - there is no “merging” of the parent’s specification and the overriding child’s.
The rules are:
- The given type must be the same or a narrower type than the type specified for the parent attribute. For example if parent specifies attribute as
Integer
, and override can narrow that toInteger[0, 100]
since all values for the child are also valid for the parent. - It is allowed to override a non constant attribute (implied required or optional, derived, derived_or_given) with a constant.
- It is not allowed to override a constant as that would break the contract.
- It is not allowed to override an attribute that is specified with
final => true
.
There is a little more to this as attributes and object functions (a.k.a methods) are in the same name space and certain forms of overriding functions with attributes and vice versa are possible, but the rules above apply also in those circumstances.
Using ‘final’
As noted in the section about ‘override’, specifying final => true
will remove the possibility to override that attribute. This feature should be used sparingly as it makes it more difficult to reuse types, but it may be warranted for things related to assertions or security - for example you would not want a child type to be able to “lie”.
Matching on Type
With inheritance matching naturally matches on the type and all of the parent types. For example, using Car
and Vehicle
example:
$a_car = Car(
reg_nbr => 'ABC 123',
manufacturer => 'Lamborghini'
)
notice $a_car =~ Car # notices true
notice $a_car =~ Vehicle # notices true
notice $a_car =~ Object # notices true
It is currently not possible to match on individual traits/interface - for example this returns false:
notice $a_car =~ Object['attributes' => { 'reg_nbr' => String }]
This does not match even if the value $a_car
is a Car
and it has an attribute named reg_nbr
since it is only matching on the type itself and the object specification used to match creates another (anonymous) subtype of Object.
In the Ruby API it is possible to get the type information (i.e. there is “reflection”), but that is not yet available in the Puppet Language without having to jump through hoops.
Why use Objects
You probably wonder when you should be using objects instead of just plain-old-hashes, they are after all quite similar, especially if you typed the hash as a Struct and gave it an alias.
type MyThing = Struct[ 'reg_nbr' => String, 'manufacturer' => String]
That is pretty much the same as the Car
data type we defined earlier. Here is what the differences are:
- The struct value needs to be type checked every time it is given to a function as the underlying value is just a Hash - it does not know if it matches any particular structs at all.
- In contrast, an Object is “type checked” when it is created and therefore it is enough to to just check if something is a
Car
as that implies that the data types for all of its attributes have been checked and found to be ok. - There is no inheritance among
Hash
/Struct
- if you want something likeCar
andVehicle
you have to repeat all of the “parent” struct definitions in a “child” struct. This become a chore and maintenance nightmare if there are many different data types with many common traits. (Some technologies that you may want to manage with Puppet has very rich configuration data for example). - Objects support polymorphic behavior - i.e. you can define methods / functions that operate on objects such that different types of objects have different behavior. While you can write functions that operate on structs of certain subtypes of Struct, you cannot select which one to call based on the kind of struct without having prior knowledge of all such structs and having them in a large case expression (or a bunch of if-then-else). More about this in a later blog post.
Summary
This blog post introduced the concept of inheritance between Object based data types in Puppet’s type system (Pcore). Puppet uses a classic Object Oriented single inheritance scheme.