struct+  +
1 Introduction
2 Design Goal
3 Synopsis
4 Syntax
5 Setters and Updaters
6 Rules
7 Converters
8 Warnings, Notes, and TODOs
9 Thanks
7.2

struct++

David K. Storrs

 (require struct-plus-plus) package: struct-plus-plus

1 Introduction

struct-plus-plus provides extended syntax for creating structs. It does not support field options (#:auto and #:mutable for individual fields), although those will be added. Aside from that, it’s a drop-in replacement for the normal struct form. So long as your struct does not use field options, you can literally just change struct to struct++ and your code will continue to work as before but you will now have a keyword constructor and functional setters for all the fields.

struct-plus-plus offers the following benefits over normal struct:

2 Design Goal

The intent is to move structs from being dumb data repositories into being data models in the sense of MVC programming. They should contain data that is internally consistent and valid according to business rules. This centralizes the checks that would otherwise need to be done at the point of use.

3 Synopsis

Let’s make a struct that describes a person who wants to join the military.

(define (get-min-age) 18.0)
(struct++ recruit
          ([name (or/c symbol? non-empty-string?) ~a]
           [age positive?]
           [(eyes 'brown) (or/c 'brown 'black 'green 'blue 'hazel)]
           [(height-m #f) (between/c 0 3)]
           [(weight-kg #f) positive?]
           [(bmi #f) positive?]
           [(felonies 0) natural-number/c])
 
          (#:rule ("bmi can be found" #:at-least  2           (height-m weight-kg bmi))
           #:rule ("ensure height-m"  #:transform   height-m  (height-m weight-kg bmi) [(or height-m (sqrt (/ weight-kg bmi)))])
           #:rule ("ensure weight-kg" #:transform   weight-kg (height-m weight-kg bmi) [(or weight-kg (* (expt height-m 2) bmi))])
           #:rule ("ensure bmi"       #:transform   bmi       (height-m weight-kg bmi) [(or bmi (/ 100 (expt height-m 2)))])
           #:rule ("lie about age"    #:transform   age       (age) [(define min-age (get-min-age))
                                                                     (cond [(>= age 18) age]
                                                                           [else min-age])])
           #:rule ("eligible-for-military?" #:check           (age felonies bmi) [(and (>= age 18)
                                                                                       (= 0 felonies)
                                                                                       (<= 25 bmi))])
           #:convert-for (db (#:remove '(eyes bmi)
                              #:rename (hash 'height-m 'height 'weight-kg 'weight)))
           #:convert-for (alist (#:remove '(bmi eyes)
                                 #:rename (hash 'height-m 'height 'weight-kg 'weight)
                                 #:post hash->list))
           #:convert-for (json (#:action-order '(rename remove add overwrite)
                                #:post  write-json
                                #:rename (hash 'height-m 'height 'weight-kg 'weight)
                                #:remove '(felonies)
                                #:add (hash 'vision "20/20")
                                #:overwrite (hash 'hair "brown"
                                                  'eyes symbol->string
                                                  'shirt (thunk "t-shirt")
                                                  'age (lambda (age) (* 365 age))
                                                  'vision (lambda (h key val)
                                                            (if (> (hash-ref h 'age) 30)
                                                                "20/15"
                                                                val))))))
 
          #:transparent)

 

> (define bob (recruit++ #:name      'bob

                         #:age       16

                         #:height-m  2

                         #:weight-kg 100))

 

> bob

(recruit "bob" 18.0 'brown 2 100 25 0)

 

> (set-recruit-age bob 20)

(recruit "bob" 20 'brown 2 100 25 0)

 

> (recruit++ #:name 'tom)

application: required keyword argument not supplied

  procedure: recruit++

  required keyword: #:age

  arguments...:

   #:name 'tom

 

> (recruit/convert->db bob)

'#hash((age . 18.0) (felonies . 0) (height . 2) (name . "bob") (weight . 100))

 

> (recruit/convert->alist bob)

'((age . 18.0) (name . "bob") (felonies . 0) (weight . 100) (height . 2))

 

> (recruit/convert->json bob)

{"eyes":"brown","age":6570.0,"name":"bob","bmi":25,"vision":"20/20","shirt":"t-shirt","hair":"brown","weight":100,"height":2}

Note about constructors:

There are two constructors for the recruit datatype: recruit and recruit++. struct++ will generate both of these while Racket’s builtin struct generates only recruit. Only recruit++ has keywords, contracts, etc. Using the default constructor will allow you to create structures that are invalid under the field contracts. See below:

> (recruit 'tom -3 99 10000 0.2 -27 'note)

(recruit 'tom -3 99 10000 0.2 -27 'note)

 

> (recruit++ #:name 'tom #:age -3 #:height-m 99 #:weight-kg 10000 #:bmi 0.2 #:felonies -27 #:notes 'note)

recruit++: contract violation

  expected: positive?

  given: -3

  in: the #:age argument of

      (->*

       (#:age

        positive?

        #:name

        (or/c symbol? non-empty-string?))

       (#:bmi

        positive?

        #:felonies

        natural?

        #:height-m

        positive?

        #:notes

        any/c

        #:weight-kg

        positive?)

       recruit?)

  contract from: (function recruit++)

  blaming: top-level

   (assuming the contract is correct)

4 Syntax

(struct++ type:id (field ...) spp-options struct-option ...)

 

 field :    field-id

          | (field-id                   field-contract          )

          | (field-id                   field-contract   wrapper)

          | ([field-id  default-value]                          )

          | ([field-id  default-value]  field-contract          )

          | ([field-id  default-value]  field-contract   wrapper)

 

 field-contract : contract? = any/c

 

 spp-options :

               | (spp-option ...+)

 

 spp-option  :   #:make-setters? boolean? = #t

               | rule

               | converter

 

 rule :   #:rule (rule-name #:at-least N maybe-pred (field-id ...+))

        | #:rule (rule-name #:check (field-id ...+) [code])

        | #:rule (rule-name #:transform field-id (field-id ...+) (code ...+))

 

 rule-name :  string?

 

 N  : exact-positive-integer?

 

 maybe-pred :

              | (-> any/c boolean?) = (negate false?)

 

 code      : <expression>

 

 converter :    #:to-hash (convert-name (hash-option ...+))

 

 convert-name : id

 

 hash-option :   #:include      (list key ...+)

               | #:remove       (list key ...+)

               | #:overwrite    (hash [key value-generator] ...)

               | #:add          (hash [key value-generator] ...)

               | #:rename       (hash [key value-generator] ...)

               | #:default      (hash [key value-generator] ...)

               | #:post         (-> hash? any) = identity

               | #:action-order (list (or/c 'include 'remove 'overwrite

                                       'add 'rename 'default) ...+)

                      = '(include remove overwrite add rename default)

 

 key             : any/c

 

 value-generator :   (not/c procedure?)            ; use as-is

                   | <procedure of arity != 0,1,3> ; use as-is

                   | (-> any/c)                    ; call w/no args

                   | (-> any/c any/c)              ; w/current value

                   | (-> hash/c any/c any/c any/c) ; w/hash,key,current value

 

 struct-option : As per the 'struct' builtin. (#:transparent, #:guard, etc)

Note that supertypes are not supported as of this writing, nor are field-specific keywords (#:mutable and #:auto).

5 Setters and Updaters

When #:make-setters? is missing or has the value #t, it will generate a functional setter and updater for each field. When it is defined and has the value #f the setters and updaters will not be generated.

Given a struct of type recruit with a field age, the name of the setter will be set-recruit-age and the updater will be update-recruit-age. Setters receive a value, updaters receive a one-argument function that receives the current value and returns the new value.

The setters and updaters are not exported. You will need to put them in the provide line manually.

(struct++ person (name))

; set-person-name and update-person-name ARE defined

 

(struct++ person (name) (#:make-setters? #t))

; set-person-name and update-person-name ARE defined

 

(struct++ person (name) (#:make-setters? #f))

; set-person-name and update-person-name are NOT defined

 

> (set-person-name (person 'bob) 'tom)

(person 'tom)

 

> (update-person-name (person 'bob) (lambda (current) (~a current "'s son")))

(person "bob's son")

 

6 Rules

Structs always have business logic associated with them – that’s the entire point. Much of that can be embodied as contracts or wrapper functions, but if you want to enforce requirements between fields then you need rules. No one wants to code all that stuff manually, so let’s have some delicious syntactic sugar that lets us create them declaratively.

Let’s go back to our example of the recruit. In order to be accepted into the military, you must be at least 18 years of age, have no felonies on your record, and be reasonably fit (BMI no more than 25).

Bob really wants to join the military, and he’s willing to lie about his age to do that.

(define (get-min-age) 18.0)
(struct++ lying-recruit
          ([name (or/c symbol? non-empty-string?) ~a]
           [age positive?]
           [(height-m #f) (between/c 0 3)]
           [(weight-kg #f) positive?]
           [(bmi #f) positive?]
           [(felonies 0) natural-number/c])
 
           (#:rule ("bmi can be found" #:at-least  2           (height-m weight-kg bmi))
            #:rule ("ensure height-m"  #:transform   height-m  (height-m weight-kg bmi) [(or height-m (sqrt (/ weight-kg bmi)))])
            #:rule ("ensure weight-kg" #:transform   weight-kg (height-m weight-kg bmi) [(or weight-kg (* (expt height-m 2) bmi))])
            #:rule ("ensure bmi"       #:transform   bmi       (height-m weight-kg bmi) [(or bmi (/ 100 (expt height-m 2)))])
            #:rule ("lie about age"    #:transform   age       (age) [(define min-age (get-min-age))
                                                                      (cond [(>= age 18) age]
                                                                            [else min-age])])
            #:rule ("eligible-for-military?" #:check           (age felonies bmi) [(and (>= age 18)
                                                                                        (= 0 felonies)
                                                                                        (<= 25 bmi))]))
         #:transparent)

Note: In the "ensure height-m" rule it is not necessary to check that you have both weight-kg and bmi because the "bmi can be found" rule has already established that. The same applies to the "ensure weight-kg" and "ensure bmi" rules.

 

> (define bob (lying-recruit++ #:name      'bob

                               #:age       16

                               #:height-m  2

                               #:weight-kg 100))

 

> bob

(lying-recruit "bob" 18.0 2 100 25 0)

Note that Bob’s name has been changed from a symbol to a string as per Army regulation 162.11a, his age has magically changed from 16 to 18.0, and his BMI has been calculated. Suppose we try to invalidate these constraints?

  > (set-lying-recruit-felonies bob 3)

; eligible-for-military?: check failed

;   age: 18.0

;   felonies: 3

;   bmi: 25

Nope! You cannot invalidate the structure by way of the functional setters/updaters, although you could do it if you marked your struct as #:mutable and then used the standard Racket mutators. (e.g. set-recruit-felonies!)

7 Converters

When marshalling a struct for writing to a database, a file, etc, it is useful to turn it into a different data structure, usually but not always a hash. Converters will change the struct into a hash, then pass the hash to the hash-remap function in handy, allowing you to return anything you want. See the handy/hash docs for details, but a quick summary:

Note that #:overwrite provides special behavior for values that are procedures with arity 0, 1, or 3. The values used are the result of calling the procedure with no args (arity 0); the current value (arity 1); or hash, key, current value (arity 3).

Converter functions are named <struct-name>/convert-><purpose>, where ’purpose’ is the name given to the conversion specification. For example:

> (struct++ person (name age)

                   (#:convert-for (db (#:remove '(age)))

                    #:convert-for (json (#:add (hash 'created-at (current-seconds))

                                         #:post write-json)))

                   #:transparent)

> (person/convert->db (person 'bob 18))

'#hash((name . bob))

> (person/convert->json (person "bob" 18))

{"age":18,"name":"bob","created-at":1551904700}

8 Warnings, Notes, and TODOs

Some of these were already mentioned above:

9 Thanks

The words ’shoulders of giants’ apply here. I would like to offer great thanks to:

And, as always, to the dev team who produced and maintain Racket. You guys rule and we wouldn’t be here without you.