struct++
(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 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:
keyword constructor
(optional) functional setter for each field
(optional) distinct defaults for individual fields
(optional) contracts for each field
(optional) wrapper functions for each field
(optional) dependency checking between fields
(optional) declarative syntax for business logic rules
(optional) declarative syntax for converting the structures to arbitrary other values
(optional) declarative syntax for generating the struct type from other values
(optional) easy run-time introspection and reflection
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.
> (require json struct-plus-plus) > (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))]) #:make-dotted-accessors? #t #: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) > (recruit++ #:name 'tom) application: required keyword argument not supplied
procedure: recruit++
required keyword: #:age
arguments...:
#:name 'tom
> (define bob (recruit++ #:name 'bob #:age 16 #:height-m 2 #:weight-kg 100)) > bob (recruit "bob" 18.0 'brown 2 100 25 0)
> (recruit-name bob) "bob"
> (recruit.name bob) "bob"
> (recruit-age bob) 18.0
> (recruit.age bob) 18.0
> (set-recruit-age bob 20) (recruit "bob" 20 'brown 2 100 25 0)
> (recruit/convert->db bob) '#hash((age . 18.0) (felonies . 0) (name . "bob") (weight . 100) (height . 2))
> (recruit/convert->alist bob) '((age . 18.0) (felonies . 0) (name . "bob") (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}
3.1 Constructors: recruit vs recruit++
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 'red 99 10000 0.2 -27) ; VIOLATES FIELD CONTRACTS! (recruit 'tom -3 'red 99 10000 0.2 -27)
> (recruit++ #:name 'tom #:age -3 #:eyes 'red #:height-m 99 #:weight-kg 10000 #:bmi 0.2 #:felonies -27) recruit++: contract violation
expected: positive?
given: -3
in: the #:age argument of
(->*
(#:age
positive?
#:name
(or/c symbol? non-empty-string?))
(#:bmi
positive?
#:eyes
(or/c 'brown 'black 'green 'blue 'hazel)
#:felonies
natural-number/c
#:height-m
(between/c 0 3)
#:weight-kg
positive?)
recruit?)
contract from: (function recruit++)
blaming: program
(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 |
| #:omit-reflection |
| rule |
| convert-for |
| convert-from |
|
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> |
|
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 that supertypes are not supported as of this writing, nor are field-specific keywords (#:mutable and #:auto).
5 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-setters? is defined and has the value #f the dotted accessors will not be generated.
6 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") |
|
7 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) failed in struct++ rule named 'eligible-for-military?'
(type: check): 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!)
8 Converters
8.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:
#:remove <list> : delete the keys in the list from the hash
#:overwrite <hash> : change the values of existing keys or add missing ones
#:add <hash> : add one or more keys to the hash, die if they were already there
#:rename <hash> : change the names of one or more keys
#:default <hash> : if a key is there, leave it alone. If not, add it
#:value-is-default? : change the behavior of #:default so that it sets the value of missing keys or keys that match a specified predicate
#:action-order : specify in what order to apply the above options
#:post : run the resulting hash through a function that returns anything you want
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,"name":"bob","created-at":1588604438}
8.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")))
9 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.
10 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>)))
11 Warnings, Notes, and TODOs
Some of these were already mentioned above:
recruit++ checks contracts and rules etc. recruit does not
#:transform rules take 1+ expressions in their code segment. The return value becomes the new value of the target
#:check rules take exactly one expression in their code segment. If the returned value is true then the rule passed, and if it’s #f then the rule calls raise-arguments-error
Rules are processed in order. Changes made by a #:transform rule will be seen by later rules
None of the generated functions (struct-name++, set-struct-name-field-name, etc) are exported. You’ll need to list them in your provide line manually
Note: As with any function in Racket, default values are not sent through the contract. Therefore, if you declare a field such as (e.g.) [(userid #f) integer?] but you don’t pass a value to it during construction then you will have an invalid value (#f in a slot that requires an integer). Default values ARE sent through wrapper functions, so be sure to take that into account – if you have a default value of #f and a wrapper function of add1 then you are setting yourself up for failure.
See the hash-remap function in the handy module for details on what the #:convert-for converter options mean
If you include the #:prefab option then you must also include #:omit-reflection
TODO: Add more complex variations of #:at-least, such as: #:at-least 1 (person-id (person-name department-id))
TODO: Add more complex variations of #:transform that can handle multiple values at once, such as: #:transform (height weight bmi) (height weight bmi) [(values (calc-bmi #f weight bmi) (calc-bmi height #f bmi) (calc-bmi height weight #f))]
TODO: add a keyword that will control generation of mutation setters that respect contracts and rules. (Obviously, only if you’ve made your struct #:mutable)
11.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.
12 Thanks
The words ’shoulders of giants’ apply here. I would like to offer great thanks to:
Greg Hendershott, for his "Fear of Macros" essay
Alexis King (aka lexi-lambda), for teaching me a lot about macros on the racket-users list (especially this post) and providing the struct-update module (docs, code) which gave me a lot of inspiration
Ryan Culpepper, who was generous enough to sit with me at RacketCon8 and walk me through proper use of syntax classes
The members of the community for being so helpful on the racket-users list
And, as always, to the dev team who produced and maintain Racket. You guys rule and we wouldn’t be here without you.