|(require struct-plus-plus)||package: struct-plus-plus|
"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":
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
Let’s make a struct that describes a person who wants to join the military.
(struct++ recruit ([name (or/c symbol? non-empty-string?) ~a] [age positive?] [(height-m #f) positive?] [(weight-kg #f) positive?] [(bmi #f) positive?] [(felonies 0) exact-nonnegative-integer?] [(notes "")]) #:transparent)
> (define bob (recruit++ #:name 'bob
(recruit "bob" 16 2 100 #f 0 "")
> (set-recruit-age bob 18)
(recruit "bob" 18 2 100 #f 0 "")
> (recruit++ #:name 'tom)
application: required keyword argument not supplied
required keyword: #:age
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
in: the #:age argument of
(or/c symbol? non-empty-string?))
contract from: (function recruit++)
(assuming the contract is correct)
(struct++ type:id (field ...) rules 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
| (rule ...)
rule : #:rule (rule-name #:at-least N pred (field-id ...+))
| #:rule (rule-name #:check (field-id ...+) [code])
| #:rule (rule-name #:transform field-id (field-id ...+) (code ...))
rule-name : string?
| (-> any/c boolean?) = (negate false?)
code : <expression>
struct-option : As per the 'struct' builtin. (#:transparent, #:guard, etc)
Note that supertypes are not supported as of this writing. The setter functions generated by "struct-plus-plus" make use of struct-copy, which doesn’t work reliably when dealing with supertypes. See Alexis King’s module "struct-update" for more details.
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) exact-positive-integer?]) (#: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)
> (define bob (lying-recruit++ #:name '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-age bob 3)
(lying-recruit "bob" 3 2 100 25 0)
Oops, the setters don’t respect the rules! That’s still TODO and will be coming out in the next release.
Some of these were already mentioned above:
"recruit++" checks contracts and rules etc. "recruit" does not
TODO: DANGER. As of this writing, the functional setters do not respect the declarative business rules.
#: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
TODO: add a keyword that will control generation of the functional setters
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, obviously)
None of the generated functions 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.) "[(username #f) non-empty-string?]" 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.
With great thanks to Greg Hendershott for his "Fear of Macros" essay, and to Alexis King for teaching me a lot about macros over email and providing the struct-update module which gave me a lot of inspiration.
And, as always, to the dev team who produced and maintains Racket. You guys rule.