struct+  +
1 Introduction
2 Design Goal
3 Synopsis
4 Syntax
5 Constructors
6 Dotted Accessors
7 Setters and Updaters
8 Rules
9 Converters
9.1 convert-for
9.2 convert-from
10 Wrappers
11 Reflection
12 Warnings, Notes, and TODOs
12.1 Field options and why they aren’t available
13 Thanks
8.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 supertypes or field options (#:auto and #:mutable). Aside from that, it’s a drop-in replacement for the normal struct form. So long as your struct does not have a supertype or a field marked #:auto or #:mutable, 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, functional setters and updaters for all fields, and reflection data. (NOTE: See the ‘Reflection’ section below for how to handle structs with #:prefab.)

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

> (require json struct-plus-plus)
; Declare a new struct type
> (struct++ plant1 (genus) #:transparent)
;  ; create an instance in various ways
> (plant1 'Rosa) ; normal Racket struct usage

(plant1 'Rosa)

> (plant1++ #:genus 'Rosa)  ; struct-plus-plus keyword constructor

(plant1 'Rosa)

> (hash->struct++ plant1++ (hash 'genus 'Rosa)) ; create from hash

(plant1 'Rosa)

;  ; cf dotted accessors, functional setters and updaters
> (define p1 (plant1++ #:genus 'Rosa))
> (plant1.genus p1) ; plant1.genus is equivalent to plant1-genus

'Rosa

> (set-plant1-genus p1 "Helianthus") ; functional setter

(plant1 "Helianthus")

> (update-plant1-genus p1 (lambda (type) (~a type ", subtype 10"))) ; functional updater

(plant1 "Rosa, subtype 10")

;  ; Let's enforce data types. Genus names must be strings
> (struct++ plant2 ([genus string?]) #:transparent)
> (plant2 'Rosa) ; basic Racket constructor will generate a non-compliant instance

(plant2 'Rosa)

; The keyword constructor raises an error on non-compliant data
> (plant2++ #:genus 'Rosa)

plant2++: contract violation

  expected: string?

  given: 'Rosa

  in: the #:genus argument of

      (-> #:genus string? plant2?)

  contract from: (function plant2++)

  blaming: program

   (assuming the contract is correct)

> (plant2++ #:genus "Rosa")

(plant2 "Rosa")

> (hash->struct++ plant2++ (hash 'genus "Rosa"))

(plant2 "Rosa")

;  ; Additionally, let's force scientifically-accurate case onto the genus.
> (struct++ plant3 ([genus string?])
    (#:rule ("genus names are required to be lowercase with initial capital" #:transform genus (genus) [(string-titlecase genus)]))
    #:transparent)
> (plant3++ #:genus "rosa")

(plant3 "Rosa")

;  ; Same as the above but using a wrapper function instead of a transform rule
> (struct++ plant3 ([genus string? string-titlecase]) #:transparent)
> (plant3++ #:genus "rosa")

(plant3 "Rosa")

;  ; Time to go hard. Let's make a struct that describes a person who wants to join the military, even if that requires lying.
> (define (get-min-age) 18.0)
> (struct++ recruit
            ([name (or/c symbol? non-empty-string?) ~a] ; Accepts either, forces to string
             [age positive?]
             [(eyes 'brown) (or/c 'brown 'black 'green 'blue 'hazel)] ; Defaults to 'brown if not specified
             [(height-m #f) (between/c 0 3)]  ; Defaults to #f which is not a valid value but if it wasn't provided then the transform rules below will auto-calculate it
             [(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))])
             #:make-dotted-accessors? #t ; This is the default. Use #f to not generate dotted accessors
             #:make-setters? #t ; This is the default. Use #f to not generate functional setters and updaters
             ; create several functions that marshal the struct for different purposes
             #:convert-for (db (#:remove '(eyes bmi) ; Prep the struct for writing to a DB
                                #: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"  ; overwrite value
                                                    'shirt (thunk "t-shirt") ; use the result of the thunk as the value for 'shirt
                                                    'eyes symbol->string ; one-argument function so run the current value through and use the result
                                                    'age (lambda (age) (* 365 age)) ; same, except the function is custom
                                                    'vision (lambda (h key val) ; three-argument function so pass the hash, the key, and the current value and use the result
                                                              (if (> (hash-ref h 'age) 30)
                                                                  "20/15"
                                                                  val))))))
  
            #:transparent)
;  ; keyword constructor exists and requires several fields
> (recruit++ #:name 'tom)

application: required keyword argument not supplied

  procedure: recruit++

  required keyword: #:age

  arguments...:

   #:name 'tom

> (define bob
    (recruit++ #:name 'bob
               #:age 16 ; Bob isn't old enough for the military so he will lie and say he's 18
               #:height-m 2
               #:weight-kg 100))
; Note that Bob's name is now a string, his age was changed, his BMI was calculated, and his felonies defaulted to 0
> bob

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

;  ; side-by-side of the dotted accessors and standard accessors to show equivalence
> (list (recruit.name bob) (recruit-name bob))

'("bob" "bob")

> (list (recruit.age bob) (recruit-age bob))

'(18.0 18.0)

;  ; various conversion functions that marshal to different forms
> (recruit/convert->db bob)

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

> (recruit/convert->alist bob)

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

> (recruit/convert->json bob)

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

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

             | #:make-dotted-accessors? boolean? = #t

             | #:omit-reflection

             | rule

             | rules

             | convert-for

             | convert-from

 

rule :  #:rule rule-clause

 

rules :  #:rules (rule-clause ...+)

 

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

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

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

 

rule-name :  string?

 

N  : exact-positive-integer?

 

maybe-pred :

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

 

convert-for :    #:convert-for (convert-name (hash-option ...))

 

convert-from :   #:convert-from (convert-name (source-predicate match-clause (field-id ...)))

 

convert-name : id

 

source-predicate : predicate/c

 

match-clause : <expression>

 

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: Supertypes are not supported as of this writing, nor are field-specific keywords (#:mutable and #:auto). See below for why.

Note: Rules can be specified using either multiple #:rule clauses or a single #:rules clause that will be unpacked to be the equivalent set of #:rule clauses. In both cases the syntax of the clauses is exactly the same, so it’s mostly an aesthetic preference.

5 Constructors

After you have used (struct++ recruit ...) to generate a recruit type, there are three ways to create an instance of that type:

In most cases the one you want will be recruit++. It uses keywords, checks the field contracts, executes business logic as defined in rules, etc.

The hash->struct++ function will accept a function and a hash where the keys are symbols matching the name of the fields in a struct++ declaration. A new instance of the struct type will be created using the specified function with the hash keys used as keyword arguments. Assuming you are passing one of the struct-plus-plus keyword constructor functions this means that field contracts will be checked, wrappers applied, etc.

The recruit constructor is the standard one that Racket generates from the (struct ...) declaration. Using it will allow you to create structures that are invalid under the field contracts. See below:

> (require struct-plus-plus)
> (struct++ flower ([genus string?][color symbol?]) #:transparent)
;  ; struct-plus-plus keyword constructor
> (flower++ #:genus "Rosa" #:color 'red)

(flower "Rosa" 'red)

;  ; convert from a hash via the struct-plus-plus keyword constructor
> (hash->struct++  flower++ (hash 'genus "Heliantus" 'color 'yellow))

(flower "Heliantus" 'yellow)

;  ; keyword constructor chokes on data that violates field contracts
> (flower++ #:genus 184 #:color #f)

flower++: contract violation

  expected: symbol?

  given: #f

  in: the #:color argument of

      (-> #:color symbol? #:genus string? flower?)

  contract from: (function flower++)

  blaming: program

   (assuming the contract is correct)

;  ; ditto when used through hash->struct++
> (hash->struct++  flower++ (hash 'genus 998 'color 17))

flower++: contract violation

  expected: symbol?

  given: 17

  in: the #:color argument of

      (-> #:color symbol? #:genus string? flower?)

  contract from: (function flower++)

  blaming: program

   (assuming the contract is correct)

;  ; constructor function given to `hash->struct++` is assumed to be a keyword function, so don't do this:
> (hash->struct++  flower (hash 'genus 998 'color 17))

application: procedure does not accept keyword arguments

  procedure: flower

  arguments...:

   #:color 17

   #:genus 998

;  ; default Racket constructor does not check field constraints
> (flower 184 #f)

(flower 184 #f)

6 Dotted Accessors

Racket’s default accessor construction can be confusing. For example:

(remote-server-send-ch foo)

Is that retrieving the value of the server-send-ch field in the remote struct, or is it retrieving the value of the send-ch field in the remote-server struct?

Compare the less ambiguous version:

(remote-server.send-ch foo)

When #:make-dotted-accessors? is missing or has the value #t, struct++ will generate a dotted accessor for each field. When #:make-dotted-accessors? is defined and has the value #f the dotted accessors will not be generated.

7 Setters and Updaters

When #:make-setters? is missing or has the value #t, struct++ will generate a functional setter and updater for each field. When #:make-setters? 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")

 

8 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))]))
  
            #:transparent)

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" 16 2 100 #f 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 #f)

set-lying-recruit-felonies: contract violation

  expected: natural-number/c

  given: #f

  in: the 2nd argument of

      (->

       lying-recruit?

       natural-number/c

       lying-recruit?)

  contract from:

      (function set-lying-recruit-felonies)

  blaming: program

   (assuming the contract is correct)

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!)

There are two separate but equivalent formats for declaring rules: the #:rule keyword followed by a rule clause or the #:rules keyword following by a parenthesized sequence of rule clauses. It’s mostly an aesthetic choice and they can be intermixed.

> (struct++ animal
            ([name (or/c symbol? non-empty-string?) ~a]
             [age integer?])
            (
             #:rules (["pointlessly check name" #:check (name)        [#t]]
                      ["have a birthday"        #:transform age (age) [(add1 age)]])
             #:rule ("name is >= 2 characters " #:check (name) [(>= (string-length (~a name)) 2)])
             #:rules (["pointlessly check age"  #:check (age) [#t]]))
  
            #:transparent)
> (animal++ #:name 'fido #:age 0)

(animal "fido" 1)

(NOTE: Although you *can* intermix #:rule and #:rules, you probably shouldn’t, as changes caused by transform rules in a #:rules clause are not visible in a later #:rule clause. This is a bug and will be fixed eventually.)

9 Converters

9.1 convert-for

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).

convert-for 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,"created-at":1634175544,"name":"bob"}

9.2 convert-from

convert-from functions go the opposite direction from convert-for – they accept an arbitrary value and they turn it into a struct.

convert-from functions are named <source>-><struct-name>++, where ’source’ is the name given to the conversion specification. For example:

> (require struct-plus-plus)
> (struct++ key ([data bytes?]) #:transparent)
> (struct++ person
            ([id exact-positive-integer?]
             [name non-empty-string?]
             [(keys '()) list?])
  
            (#:convert-from (vector (vector?
                                     (vector id
                                             (app (compose (curry map key) vector->list) keys)
                                             name)
                                     (id keys name))))
  
            #:transparent)
> (vector->person++ (vector 9 (vector #"foo" #"bar") "fred"))

(person 9 "fred" (list (key #"foo") (key #"bar")))

Behind the scenes, the #:convert-from specification above is equivalent to the following:

> (require struct-plus-plus)
> (struct++ key ([data bytes?]) #:transparent)
> (struct++ person
            ([id exact-positive-integer?]
             [name non-empty-string?]
             [(keys '()) list?])
             #:transparent)
> (define/contract (vector->person++ val)
    (-> vector? person?)
    (match val
      [(vector id
               (app (compose (curry map key) vector->list) keys)
               name)
       (person++ #:id id #:keys keys #:name name)]))
> (vector->person++ (vector 9 (vector #"foo" #"bar") "fred"))

(person 9 "fred" (list (key #"foo") (key #"bar")))

10 Wrappers

All fields have wrappers; either you set one or the wrapper is identity. Values go through the wrapper whenever the struct is created or when a setter/updater is called. The return value of the wrapper is what is actually stored in the struct.

11 Reflection

By default, all struct++ types support reflection by way of a structure property, ’prop:struct++’, which contains a promise (via delay) which contains a struct++-info struct containing relevant metadata.

Use the #:omit-reflection keyword to disable this behavior. You will need to do so if you are including the #:prefab struct option.

Relevant struct definitions:

> (struct++ person ([name (or/c symbol? string?) ~a]
                    [(age 18) number?]
                    [eyes])
            (#:rule ("name ok" #:check (name) [(> (string-length name) 3)])
             #:rule ("is >= teen" #:check (age) [(>= age 13)])
             #:convert-for (db (#:add (hash 'STRUCT-TYPE 'person))))
            #:transparent)
> (define bob (person 'bob 18 'brown))
> (struct++-ref bob)

#<promise!#<struct++-info>>

> (force (struct++-ref bob))

#<struct++-info>

Declarations for the various types used in reflection:

(struct struct++-rule (name type))
;  contracts: string? (or/c 'transform 'check 'at-least)
;  e.g.: "name ok" 'check
 
(struct struct++-field (name accessor contract wrapper default))
;  e.g.: 'name (or/c symbol? string?) ~a 'no-default-given
;  e.g.: 'age number? identity 18
 
(struct struct++-info
  (base-constructor constructor predicate fields rules converters))
;  base-constructor will be the ctor defined by @racket[struct], e.g. 'person'
;  constructor will be the ctor defined by @racket[struct++], e.g. 'person++'
;  predicate will be, e.g., 'person?'
;  converters will be a list of the procedures defined by the #:convert-for items
> (match (force (struct++-ref bob))
    [(struct* struct++-info
              ([base-constructor base-constructor]
               [constructor constructor]
               [predicate predicate]
               [fields (and fields
                            (list (struct* struct++-field
                                           ([name     field-names]
                                            [accessor field-accessors]
                                            [contract field-contracts]
                                            [wrapper  field-wrappers]
                                            [default  field-defaults]))
                                  ...))]
               [rules (and rules
                           (list (struct* struct++-rule
                                          ([name rule-names]
                                           [type rule-types]))
                                 ...))]
               [converters converters]))
  
     (pretty-print
      (hash 'field-names     field-names
            'field-accessors field-accessors
            'field-contracts field-contracts
            'field-wrappers  field-wrappers
            'field-defaults  field-defaults
            'rule-names      rule-names
            'rule-types      rule-types
            'converters      converters
            'fields          fields
            'rules           rules))])

'#hash((converters . (#<procedure:person/convert->db>))

       (field-accessors

        .

        (#<procedure:person-name>

         #<procedure:person-age>

         #<procedure:person-eyes>))

       (field-contracts

        .

        (#<flat-contract: (or/c symbol? string?)>

         #<procedure:number?>

         #<flat-contract: any/c>))

       (field-defaults . (no-default-given 18 no-default-given))

       (field-names . (name age eyes))

       (field-wrappers

        .

        (#<procedure:~a> #<procedure:identity> #<procedure:identity>))

       (fields . (#<struct++-field> #<struct++-field> #<struct++-field>))

       (rule-names . ("name ok" "is >= teen"))

       (rule-types . (check check))

       (rules . (#<struct++-rule> #<struct++-rule>)))

12 Warnings, Notes, and TODOs

Some of these were already mentioned above:

12.1 Field options and why they aren’t available

Field options (#:auto and #:mutable) are not supported and there are no plans to support them in the future.

Regarding #:auto: The per-field default syntax that struct++ provides is strictly superior to #:auto, so there is no need to provide it. Furthermore, auto fields come with the restriction that they cannot have values provided at construction time – it’s not a default, it’s a "here’s this field that is automagically generated and you can’t do anything but read it". This would substantially complicate generating the keyword constructor, since the macro would need to locate all fields that were auto and then exclude them from the constructor. Furthermore, it wouldn’t be sensible for an auto field to have a default value, contract, wrapper, or functional setter, so there would need to be an entirely separate field syntax and then many additional checks. The costs of supporting #:auto far outweigh the marginal value.

Regarding #:mutable: Supporting this one would be straightforward, so not supporting it is a deliberate choice. The functional setters that struct++ provides should satisfy nearly all the same use cases as the #:mutable field option, and it’s still possible to use the struct-level #:mutable option if you really want to mutate. Mutation should be avoided in general, so leaving out the #:mutable field option seems like a good decision.

13 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.