A deep dive into Guix records

by Julien Lepiller — dim. 17 juillet 2022

The records are an essential part of the Guix API. Many objects manipulated by Guix are records: from packages to operating-systems, origins, service configuration, manifests, etc. So what are they, and do you use them?

This article is an attempt to give you a more in-depth understanding of what a record is and what you can and cannot do with them. But first, what's a record and why is it called that way?

Records in Guile

In programming languages, especially functional programming languages, records are a common data structure. A record is way to group data together. It is a sort of key-value structure where each key (called a field) is associated with a value.

Now, a record does not accept any kind of fields. Before you can create a record, you need to define a record-type. The record type basically dictates what fields are allowed in the record. In fact, it lists all the fields that must be present in a record of this type.

Guile provides records and record-types in its (srfi srfi-9) module. To declare a new record type, you can use:

(use-modules (srfi srfi-9))

(define-record-type foo
  (make-foo field1 field2 field3)
  foo?
  (field1 foo-field1)
  (field2 foo-field2)
  (field3 foo-field3))

Now let's decompose this a bit.

First, we are calling a macro, define-record-type that… well… defines a new record type. This type is called foo and the macro accepts three types of arguments:

  • The constructor, (make-foo field1 field2 field3). It creates a new procedure with the same name as the first element, so make-foo in our case. By convention, the constructor is always called make-<name-of-record-type>. Then, it is followed by the names of the fields in the record, here field1 to field3.
  • A predicate name, foo?. This creates a new procedure with that name, that takes a single argument and returns #t only if it is a record of the type that is being defined.
  • A list of field declarations. Each field is composed of the name of the field and an accessor name for that field. The accessor name creates a new procedure with that name, that takes a single argument and returns the value associated with the field being defined in its argument, if the argument is a record of the type being defined.

OK, this might have been confusing already. To summarize, define-record-type defines:

  • A constructor, to create a record of that type,
  • A predicate, to check whether a record is of that type,
  • Accessors, to retrieve values associated with the fields of a record.

To illustrate, let's first define a record that does something useful:

(define-record-type <point>
  (make-point x y)
  point?
  (x point-x)
  (y point-y))

As we said, we can create a record of that type by using the constructor that was defined. The constructor takes exactly the same arguments as the ones that are declared in the record type, so creating a point looks like this:

(define my-point (make-point 1 5))
;; scheme@(guile-user)> my-point
;; $1 = #<<point> x: 1 y: 5>

As you can see, we have created a point that holds coordinates. Using the REPL, we can see the values associated with the fields. Now let's see how to get these coordinates back:

(display (point-x my-point))
(newline)
;; 1
(display (point-y my-point))
(newline)
;; 5

And we can check that my-point is indeed a point with the predicate we defined:

(if (point? my-point)
    (display "yes\n")
    (display "no\n"))
;; yes

So this is the basic knowledge you need to have about records in Guile. Some more observations can be made though, and we're going to explore some of the points that might be confusing when you start learning about records in the next sections.

For the rest of this section, I will need the following types:

;; pretty much the same as <point> above, but with one more coordinate
(define-record-type <3d-point>
  (make-3d-point x y z)
  3d-point?
  (x 3d-point-x)
  (y 3d-point-y)
  (z 3d-point-z))

Record types are disjoint

What I mean by that is that if you have two record types, a record can only be one of these two types, not both at the same time. So in our example, a <point> is not a <3d-point> and conversely, a <3d-point> is not a <point>:

(define my-other-point (make-3d-point 5 9 6))
;; scheme@(guile-user)> (point? my-other-point)
;; $1 = #f
;; scheme@(guile-user)> (3d-point? my-other-point)
;; $2 = #t
;; scheme@(guile-user)> (point? my-point)
;; $3 = #t
;; scheme@(guile-user)> (3d-point? my-point)
;; $4 = #f

Accessors are type-specific

What I mean by that is that you can't use an accessor defined in a type to access a field in another record, even if it has the same name, or if it is at the same position:

;; scheme@(guile-user)> (point-x my-other-point)
;; ice-9/boot-9.scm:1685:16: In procedure raise-exception:
;; In procedure point-x: Wrong type argument: #<<3d-point> x: 5 y: 9 z: 6>

That is because you're trying to access field x of record type <point>, but you give it a <3d-point>.

Playing with records

So, now that we understand how record work and don't work, let's try a little exercise. Let's try defining a procedure to addition two points. If you don't know what that means, let's just say we want a procedure that takes two points a returns a new points whose coordinates are the sum of coordinates of the two points. So adding point with coordinate (1,5) with point (4,-2) gives a point with coordinates (5,3). Sounds easy?

The solution will be a procedure, say add-points that takes two <points>, like this:

(define (add-points p1 p2)
  ;; do something...
  )

With what we learned above, you should be able to write that procedure by yourself. Give it a try!

Here's a possible solution:

(define (add-points p1 p2)
  (make-point
    (+ (point-x p1) (point-x p2))   ; X coordinate
    (+ (point-y p1) (point-y p2)))) ; Y coordinate

Record type modifiers

In reality, Guile records can also add modifiers to their field declarations. We are not covering them here, you can read the manual for more information if you'd like.

Guix records

Now that we've seen the basics of records in Guile, let's talk about Guix records. Confusingly, Guix also defines its own types of record types. (guix records) is the module you need to create new Guix record types.

Note: from now on we are going to use Guix records. If you want to follow what we are doing here, you should open a new guix repl. We're going to define records with the same name as above. Make sure to start from a new REPL or you could get some confusing error messages later on.

Defining a Guix record (from now own, a record) type is very similar to Guile records, so for a simple point like we did above:

(use-modules (guix records))
(define-record-type* <point> point
  make-point
  point?
  (x point-x)
  (y point-y))

First, you'll notice that we use a different macro to define this record type, define-record-type*. The * is conventionally used to mean “same but different”. It does the same thing, but slightly differently.

First, there are more things declared at the beginning of the record type:

  • The name: as before, it's <point>. For these kind of record types, it is usually wrapped into <>.
  • The syntactic constructor, it's point. This is the constructor you are going to interact with most of the time.
  • The constructor, it's make-point. This is the same constructor as for Guile records, but you're not going to interact with it. Notice that this time, it is not necessary to re-declare all the field names inside. We just need to define a name for the constructor.
  • The predicate, it's point?. This is the same as the predicate for Guile records.
  • The fields, similar to Guile records.

So, compared to Guile records, the main difference is the syntactic constructor. This is how you use it:

(define my-point
  (point
    (x 1)
    (y 5)))
;; scheme@(guile-user)> my-point
;; $1 = #<<point> x: 1 y: 5>

The syntactic constructor makes the field names explicit. How is that useful you ask? For big records, it's quite handy to be able to name records like that. You can also reorder them, like so:

(define my-point
  (point
    (y 5)
    (x 1)))
;; scheme@(guile-user)> my-point
;; $1 = #<<point> x: 1 y: 5>

Not very impressive though. The main advantage of Guix records are their additional features, that we are going to study now.

Default values

Guix record types can declare a default value for a field. This can look like this:

(define-record-type* <point> point
  make-point
  point?
  (x point-x
     (default 0))
  (y point-y
     (default 2)))

Here, the default value for x is 0, and the default value of y is 2. Now that we declared a default value, we can omit these fields in the definition of a record of this type:

(define my-point
  (point)) ;; no field, all of them keep their default value
;; #<<point> x: 0 y: 2>
(define my-point
  (point
    (x 18)))
;; #<<point> x: 18 y: 2>
(define my-point
  (point
    (y -7)))
;; #<<point> x: 0 y: -7>

Refer to other fields

When constructing a record, you can refer to the value associated with a field you defined above. For instance:

(define my-point
  (point
    (x 14)
    (y (+ x 2))))
;; #<<point> x: 14 y: 16>

Thunked fields

When defining a new record type, you can make a field thunked. This means that accessing the field's value will compute it in the dynamic extent of the caller. This is mostly used with fluids in Guix, as well as a special this reference (see below).

To illustrate with fluids:

(define %current-x (make-fluid 0))
(define-record-type* <point> point
  make-point
  point?
  (x point-x (thunked))
  (y point-y (default 0)))

(define my-point
  (point
    (x (%current-x))))

;; scheme@(guile-user) (point-x my-point)
;; $1 = 0

(fluid-set! %current-x 17)

;; scheme@(guile-user) (point-x my-point)
;; $2 = 17

This reference

When defining a new record type, you can add another definition before the list of fields, a this-identifier. It is a simple name, usually something like this-<name-of-the-record-type>. It can be used in thunked fields to refer to the current record. For instance, this can be useful to define default values.

Here is an example of a point that has the same x and y coordinates by default:

(define-record-type* <point> point
  make-point
  point?
  this-point
  (x point-x (default (point-y this-point)) (thunked))
  (y point-y (default (point-x this-point)) (thunked)))
(define my-point
  (point
    (x 5)))
;; scheme@(guile-user)> (point-x my-point)
;; $1 = 5
;; scheme@(guile-user)> (point-y my-point)
;; $2 = 5

Note that the example above is very silly and a bad idea, because field x refers to field y and field y refers to field x, by default. So if you use the default values for x and y, accessing any of these points generates an infinite loop. Don't try this at home!

Inheritance

Inheritance can be used to create a new record that uses the same values as another record of the same type, while overriding some of the fields. To use inheritance, when you create a new record, you use the pseudo-field inherit. It needs to be the first field in the record declaration. Here's an example:

(define-record-type* <point> point
  make-ponit
  point?
  (x point-x)  ;notice: no default value
  (y point-y)) ; notice: no default value

(define my-first-point
  (point
    (x 1)
    (y 3)))

(define my-second-point
  (point
    (inherit my-first-point) ; all undeclared fields here are copied from my-first-point
    (y 5)))

(define my-third-point
  (point
    (inherit my-first-point) ; all undeclared fields here are copied from my-first-point
    (x 4)))

(define my-fourth-point
  (point
    (inherit my-second-point) ; all undeclared fields here are copied from my-second-point
    (x 8)))

;; scheme@(guile-user)> my-first-point
;; #<<point> x: 1 y: 3>
;; scheme@(guile-user)> my-second-point
;; #<<point> x: 1 y: 5>
;; scheme@(guile-user)> my-third-point
;; #<<point> x: 4 y: 3>
;; scheme@(guile-user)> my-fourth-point
;; #<<point> x: 8 y: 5>

Also note that you cannot refer to a field you did not declare in the current syntactic constructor. Here's an invalid example:

(define my-fith-point
  (inherit my-first-point)
  (x y))
;; ice-9/boot-9.scm:1685:16: In procedure raise-exception:
;; error: y: unbound variable

Innate fields

An innate field is a field that cannot be inherited. This is useful in Guix for location fields: they can refer to the location of the declaration instead of inheriting the location from the parent declaration. Here's an example of that in action:

(define-record-type* <point> point
  make-point
  point?
  (x point-x (default 0))
  (y point-y (default 0))
  (loc point-location (innate) (default (current-source-location))))

(define p
  (point
    (x 5)))
;; #<<point> x: 5 y: 0 loc: ((line . 23) (column . 10))>

(define p2
  (point
    (inherit p)
    (x 4)))
;; #<<point> x: 4 y: 0 loc: ((line . 25) (column . 10))>

Note how the location (esp. the line number) changed between the two: this is because the default value is used again (and re-computed) when declaring p2, even though it inherits from p. If the loc field were not declared as innate, it would be inherited and the two locations would have been the same.

Delayed fields

A delayed field is used to automatically wrap the value of the field into (delay ...). In Guix, this is used in the patches field of the origin record type. This ensures that patches are retrieved lazily, as needed, so they are not searched for everytime all the packages are accessed (eg. during package search).

It works in a similar fashion to thunked fields, but does not allow reference to this.

(define-record-type* <point> point
  make-point
  point?
  (x point-x (delayed))
  (y point-y))

(define p1 (make-point (x 1) (y (begin (sleep 5) 2))))
;; wait 5 seconds because y is not delayed
(define p2 (make-point (x (begin (sleep 5) 1)) (y 5)))
;; returns immediately because x is delayed
(point-x p2)
;; wait 5 seconds for the value to be computed
(point-x p2)
;; no more waiting, the value was computed previously

As you can see, once the value is computed, it is recorded and not computed again the next time you want to access it. This is another difference with thunk fields that will recompute every time you access them.

Sanitizer

Finally, sanitizers are procedures attached to a field that will modify the declared value. For instance, this could be used to accept more data types and convert them to a unique type, so procedures that use a record of this type do not have to manage multiple cases for the different supported types.

Here's an example where points accept numbers, and convert them to integers by rounding floating point and exact numbers.

(define (number->integer value)
  (inexact->exact (round value)))

(define-record-type* <point> point
  make-point
  point?
  (x point-x (sanitize number->integer))
  (y point-y (sanitize number->integer)))

(define p
  (point
    (x 1.5)
    (y (/ 3 7))))
;; #<<point> x: 2 y: 0>

Conclusion

Now you know everything there is to know about Guix records! Happy Guix hacking!