Puppet 6 Type System - Object Methods
Introduction
This is the fourth 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, and the third post is about inheritance. In this post, I will cover Object behavior - that is the operations that are available on Object, how they can be controlled, and how methods/functions are defined and implemented.
Terminology Corner: Behaviour; Methods or Functions or Operations…
According to Inform IT (adapted to Puppet terminology):
The behavior of an object is defined by its methods, which are the functions and subroutines defined within the object type. Without defined methods, an object type would simply be a structure. Methods determine what type of functionality an object type has, how it modifies its data, and its overall behavior
In addition there are operations that can be performed on objects; they can be created, matched against a data type, its methods can be called, they can be given to functions as arguments, set as attributes of Puppet resources, they can be serialized etc.
Built in Behaviours
Creation
All objects can be created by either giving arguments in the order the attributes are defined, or by giving the arguments by name in a hash.
$car1 = Car('ABC 123')
$car2 = Car(regnbr => 'XYZ 666')
See earlier posts in this series for more examples.
Attribute Accessors (or “getters”)
All attributes can be read by calling a method named the same as the attribute. Recall that the .
operator is a method call - so when we earlier did something like:
$a_car = Car('ABC 123')
notice $a_car.reg_nbr
The part $a_car.reg_nbr
is actually a call, and we could have emphasized that by writing $a_var.reg_nbr()
, but the parentheses are not needed when there are no arguments (and when getting the value of an attribute, there is never any arguments).
The fact that every attribute is available via a “getter” method is why attributes and object functions share the same namespace.
Attribute Setters Does not Exist
There are no “setters”. Attributes can only be set when an object is created.
Equality; ==
and !=
and being a key in a hash
All objects support the equality operators ==
and !=
as well as supporting being used as hash keys without any extra burden on the implementor (i.e. a “hash key” computation is provided automatically). It is possible to define the set of attributes that are used when determining if two objects are equal or not.
Not yet available Object features
The following features from the Pcore specification have not yet been implemented:
- Comparison support - i.e. being able to compare objects with operators <, <=, >, >=.
- Reflection in the Puppet Language (full reflection is available in Ruby, but this is not exposed in the Puppet Language). Reflection means being able to get the Pcore definition of the type of an instance.
- Support for invariants - the ability to specify constraints on a combination of attributes.
- No support for matching an object against a dynamic interface - similar to
respond_to?
in Ruby - it is only possible to match on the type (and its parent types).
- It is not yet possible to dynamically call methods as the
call()
function does not support this (at least not in Puppet 6.0.0).
- As discussed below the
equality_include_type => false
feature is not working as intended.
Equality
As stated earlier, the default built in support for equality compares all non-constant attributes and two objects are equal if they have the same type, and all attributes have equal value.
This is however not always what you want. You may for example want to have a single “primary key” or a “compound primary key” that is a smaller set of attributes than all of them, you may also want to skip comparing the subtype. As an example - say you want the primary key to be the “license plate” registration number of the car.
This is achieved by defining the equality property of the object:
type Car = {
attributes => {
reg_nbr => String,
color => String,
},
equality => 'reg_nbr'
}
$car1 = Car('reg_nbr' => "ABC 123", 'color' => 'black')
$car2 = Car('reg_nbr' => "ABC 123", 'color' => 'pink')
$hash = { $car1 => 'this one' }
notice $hash[$car2]
notice $car1 == $car2
This would notice "this one"
even though the $car2
is "pink"
, and then notice true
as the two cars are equal now that we modified the set of attributes included in what is considered “representing the same car”.
The equality can be specified as a single attribute name, or an array of attribute names that are to be matched in order for two objects to be considered equal.
Class Equality
What if we want to keep the registration number of a car in the parent class and only use the registration number when computing equality among all of the sub types? Well, the specification contains a feature for this, but it is unfortunately not working as intended.
The following option:
equality_include_type => false,
The equality_include_type
is intended to turn off the automatic comparison on type - but it does not work. Thus it is currently impossible to make a value equal to another value even when the types of the values shares a common ancestor with equality_include_type
is false
and there are no additional attributes specified for equality.
While this is not a very common thing to do, I am showing this here to save you frustration in case you have such a case and try to use this.
Additional Equality Rules
-
When inheriting an equality definition, the specification of a child’s equality definition is concatenated to the aggregated ancestor definition.
-
It is an error to include an attribute more than once in the same equality definition. It is allowed to include an inherited attribute, but only if it is not already part of an ancestor’s equality definition.
-
It is not possible to modify the definition of a parent type’s equality, only extend it.
Using an Object as key in a Hash
All objects support being used as hash keys without any extra burden on the implementor. As an example:
type Car = {
attributes => {
reg_nbr => String,
color => String,
}
}
$car1 = Car('reg_nbr' => "ABC 123", 'color' => 'black')
$car2 = Car('reg_nbr' => "ABC 123", 'color' => 'black')
$hash = { $car1 => 'this one' }
notice $hash[$car2]
notice $car1 == $car2
This would notice “this one”, and true even though the hash lookup is done with a different instance of Car
- it works as a key because it has the exact same attribute values and the default for an Object is to compare all non-constant attributes. See"Equality" above how this can be changed.
Defining Object Functions
An Object’s behavior (over and beyond what Objects already support) is defined by defining Object Functions. When an object with defined object functions is used there must exist an implementation of those functions at runtime. At present it is not possible to define such functions in the Puppet Language.
How this works was shown at the end in the first blog post in this series where there is a longer example with both derived attributes and custom methods. Here I will instead add some details about how object functions can be defined.
Notably, the functions share name space with attributes - this is because an attribute “getter” is also a functions named after the attribute. (You are protected by making a mistake since if an object has parents it is not allowed to override a parent attribute or function unless also specifying override => true
).
An Object function is defined by giving the Object type a
specification function => { name => Callable, ...}
where
name
is the name of the function, and Callable
is a type signature for the function that specifies the number of arguments and their data type and with an optional specification of returned
data type.
Here is an excerpt from the example in the First Post:
functions => {
resize => Callable[[Integer[1], Integer[1]], MyModule::Image],
image_bytes => Callable[[], Binary]
}
Just as with specification of attributes - there is a short form (as seen above) where a function name is simply mapped to a Callable, and a long form where it is possible to specify the options for the function.
Function Options
name |
type |
meaning |
annotations |
- |
Advanced: Allows association of annotations - to be explained in a separate blog post |
final |
Boolean |
A final function cannot be overridden in sub types. |
override |
Boolean |
Must be set to true if overriding a parent type attribute or function. An error is raised if attribute/function does not exist in a parent type. |
type |
Type[Callable] |
the type signature of the object function. |
Thus the equivalent long notation of this short notation:
functions => {
image_bytes => Callable[[], Binary]
}
is this:
functions => {
image_bytes => { type => Callable[[], Binary] }
}
And it is now possible to specify other options - here making the method override a parent type definition of the same function and also making this overriding definition final.
functions => {
image_bytes => {
type => Callable[[], Binary],
override => true,
final => true,
}
}
The concepts final
and override
where described in the Third Post in this series - and it works the same way for functions as for attributes.
Callable Type
You find all the details about the Callable
data type in the Language Specification of Callable.
In brief - a Callable
with return type is written on the form
Callable[ [<parameter types>], <return_type>]
. If return type is not specified it defaults to Any
, and the parentheses around the parameter types can be omitted: Callable[<parameter types>]
The <parameter types>
is simply a list of data types, one per parameter, optionally ending with min, max numbers indicating that the last parameter is a repeating parameter for which a min number of arguments must be given, and no more than the specified max number of arguments. There are additional special cases that are explained in the specification.
Callable[Integer, Integer]
Callable[Integer, 0, 5]
Callable[[Integer, 0, 5], Integer]
A callable can also describe that the callable accepts a code block (lambda). This is described with a Callable
(or Optional[Callable]
) and it is placed last among the parameters (after min/max if they are given).
This example says that the function may be called with 0-5 integers and that it optionally accepts a lambda that accepts a single integer argument:
Callable[Integer, 0, 5, Optional[Callable[Integer]]]
Summary
In this post I covered the built in operations available on objects and more details about how to specify object functions.