Define-attributes macro, is this useful, ideas for improvements?

I had a define-attributes macro for a while now in a project, I don't use it a whole lot, but where I do it seems to make the code less verbose. But I think my understanding of source locations is still not the best so the macro may need some tweaks in that regard.

In this post I used it to illustrate a point (at the end) and that got me thinking, maybe this could be useful for others.

Here is some code that illustrates how I use it:

(define (vec3-squared-length v)
  (define-attributes ([v]) vec3- (x y z))
  (+ (x² x) (x² y) (x² z)))

(define (vec3-normalize v)
  (define-attributes ([v]) vec3- (x y z [length l]))
  (if (= 0 l)
      v
      (vec3 (/ x l) (/ y l) (/ z l))))

(define (matrix4-from-rotation-axes x y z)
  (define-attributes (x y z) vec3- (x y z))
  (matrix4  xx  yx  zx 0
            xy  yy  zy 0
            xz  yz  zz 0
             0   0   0 1))

(define (matrix4-from-translation v)
  (define-attributes ([v]) vec3- (x y z))
  (define _ 0)
  (matrix4 1 _ _ x
           _ 1 _ y
           _ _ 1 z
           _ _ _ 1))

Here is the current implementation:

(require (for-syntax syntax/parse))

(provide define-attributes)

(begin-for-syntax
  (define (symbol-concat l r)
    (define s1 (syntax-e l))
    (define s2 (syntax-e r))
    (string->symbol (string-append (symbol->string s1)
                                   (symbol->string s2))))

  (define-syntax-class name-mapping
    #:description "name-mapping"
    [pattern from:id
             #:with to (syntax-e #'from)]
    [pattern (from:id)
             #:with to '||]     ; empty symbol
    [pattern (from:id to-id:id)
             #:with to (syntax-e #'to-id)]))

;(define-attributes (l r)    vec3- (x y z))  lx ly lz rx ry rz
;(define-attributes ([l])    vec3- (x y z))  x y z
;(define-attributes ([l o])  vec3- (x y z))  ox oy oz
;(define-attributes ([l l.]) vec3- (x y z))  l.x l.y l.z
;(define-attributes ([l])    vec3- (x y z [length l]))  x y z l
(define-syntax (define-attributes stx)
  (syntax-parse stx
    [(_ (ids:name-mapping ...) prefix:id (attributes:name-mapping ...))
     (define (mapping keys values)
       (make-hasheq (map cons
                         (syntax->list keys)
                         (syntax->list values))))

     (define prefix-map (mapping #'(ids.from ...)
                                 #'(ids.to ...)))
     (define suffix-map (mapping #'(attributes.from ...)
                                 #'(attributes.to ...)))

     (define definitions
       (for*/list ([id   (in-list (syntax->list #'(ids.from ...)))]
                   [attr (in-list (syntax->list #'(attributes.from ...)))])
         (cons (datum->syntax stx (symbol-concat (hash-ref prefix-map id)
                                                 (hash-ref suffix-map attr)))
               (with-syntax ([accessor (datum->syntax stx (symbol-concat #'prefix attr))])
                 #`(#,#'accessor #,id)))))

     (define identifiers (map car definitions))
     (define expressions (map cdr definitions))
     #`(define-values (#,@identifiers) (values #,@expressions))]))

Here are a few questions:

Do you have ideas how this macro could be improved?
-> source locations: format-id + #:subs #t, functional-style macro implementation

Do you find the macro useful?
Do you know of similar macros?
Do you prefer other ways of doing the same/similar thing?
-> seems useful and a bit similar to match-define, with a twist

Should I create a package for this?
Should it be a tiny package? (I think that might make it most likely that someone actually uses it)
-> for now I won't create a package for this, I may add it to some package in the future, but also feel free to do what you want with the code (public domain)

source locations

I am not quite sure what is possible with source locations and macro introduced bindings.
I am not a regular drracket user, but this macro doesn't show an arrow for the bindings that are created by this macro.
I tried to add source location information to the newly generated bindings in a few different ways, hoping that would cause drracket to draw an arrow from the use, to the corresponding id in (define-attributes (x y z) ...), but that didn't seem to do anything.
Are arrows only for 1:1 mappings?
Is there a way to have something similar for identifiers that were introduced based on other identifiers,
or am I simply missing something in my implementation?
(Maybe I need a function that lifts the define-values to the surrounding internal definition context so that the newly introduced identifiers become more visible? But I think that already happens automatically...)

In general I am not sure whether this macro seems "rackety" to other people, the way it creates new identifiers based on suffixes.

1 Like

Use

(datum->syntax 
     stx 
     (symbol-concat (hash-ref prefix-map id)
                    (hash-ref suffix-map attr)) 
     stx
     stx)

to get an arrow-connection to lx from

(define-attributes (l r) vec3- (x y z))

Adjust these extra arguments to your needs.

2 Likes

format-id also can handle some of the details of making identifiers from parts of strings, symbols, and identifiers.

2 Likes

The macro seems useful: it reminds me a bit of match-define for pulling out pieces of structs or similar, but orthogonally has this sort of cross-product going on with mappings, prefixes, and attributes. I wonder what a match-expander would look like?

I agree with @SamPhillips that you probably want format-id with its #:source argument: reading, the macro feels very procedural (à la CommonLisp) rather than pattern-matching (à la, well, Racket). Perhaps with judicious use of format-id, you could get the template to something like

#'(define-values (to ...) (values exp ...))

Some amount of procedural-like code is necessary to break hygiene, but I think #:with, format-id, and templating could get there. I'll give it a shot if I have some time (unlikely).

2 Likes

Like @benknoble suggested I tried to rewrite the macro using more pattern matching, at first that seemed a bit difficult. But by using a helper macro, fixing my syntax-class (so that it keeps the original identifiers in #:with to ...) and using format-id like @SamPhillips suggested, I managed to get it quite conscise and now it shows arrows properly. Here is the new version:

#lang racket

(provide define-attributes)

(require syntax/parse/define
         (for-syntax racket/syntax))

(begin-for-syntax
  (define-syntax-class name-mapping
    #:description "name-mapping"
    [pattern from:id
             #:with to #'from]
    [pattern (from:id)
             #:with to '||]     ; empty symbol
    [pattern (from:id to-id:id)
             #:with to #'to-id]))

;(define-attributes (l r)    vec3- (x y z))  lx ly lz rx ry rz
;(define-attributes ([l])    vec3- (x y z))  x y z
;(define-attributes ([l o])  vec3- (x y z))  ox oy oz
;(define-attributes ([l l.]) vec3- (x y z))  l.x l.y l.z
;(define-attributes ([l])    vec3- (x y z [length len]))  x y z len
(define-syntax-parser define-attributes
  [(_ (ids:name-mapping ...+) prefix:id (attributes:name-mapping ...+))
   #`(begin (define-attributes-id #,this-syntax ids prefix attributes ...) ...)])

(define-syntax-parser define-attributes-id
  [(_ loc id:name-mapping prefix:id attribute:name-mapping)
   #:with newid    (syntax-property
                    (format-id #'id "~a~a" #'id.to #'attribute.to #:subs? #t)
                    'original-for-check-syntax #t)
   #:with accessor (format-id #'loc "~a~a" #'prefix #'attribute.from #:source #'loc)
   #:with expr     #'(accessor id.from)
   #'(define newid expr)]
  [(_ loc id:name-mapping prefix:id attributes:name-mapping ...+)
   #'(begin (define-attributes-id loc id prefix attributes) ...)])

;; example
(struct vec3 (x y z))

(define (x² x) (* x x))
(define (vec3-length v)
  (define-attributes ([v]) vec3- (x y z))
  (sqrt (+ (x² x) (x² y) (x² z))))

(define-syntax-rule (fmt x ...) (begin (displayln (~a (~a (quote x) ": " x "  ") ...))))

(module+ main
  (define l (vec3 3 5 7))
  (define r (vec3 0 2 4))

  (define-attributes (l r)    vec3- (x y z)) ; lx ly lz rx ry rz
  (define-attributes ([l])    vec3- (x y z)) ; x y z
  (define-attributes ([l o])  vec3- (x y z)) ; ox oy oz
  (define-attributes ([l l.]) vec3- (x y z)) ; l.x l.y l.z

  (fmt lx ly lz rx ry rz)
  (fmt x y z)
  (fmt ox oy oz)
  (fmt l.x l.y l.z)

  (let ()
    (define-attributes ([l])    vec3- (x y z [length len]))  ; x y z len
    (fmt x y z len)))

racket_attributes_3fps

4 Likes

Could you make a package for this macro?

yes, but this reactivates an issue for me about single package vs split packages.

I think I will create a separate topic about that.

I created a package define-attributes, as an exercise of playing through what that other topic is about, I created it as a single package.

I think in the future with some new solution it would be good if this was enough.
With the current status quo I think it isn't.
In the documentation of this package I used pict-lib to create a picture that is shown in the documentation.
I think it is unreasonable for users of define-attributes(-lib) to now not only have a dependency on rackunit-lib, scribble-lib and racket-doc but also on pict-lib.

So for now, to make the package usable with the status quo, I will split the package in define-attributes and define-attributes-lib, where the former additionally contains documentation and tests.

Cool macro, and thanks for packaging it up. I played with it for a bit and found that the base case works well but I'm confused about the 'length' bit. Here's the test code I used, which should be straight copy/paste from the documentation:

#lang racket

(require define-attributes)

(struct vec3 (x y z))
(define l (vec3 3 5 7))
(define r (vec3 0 2 4))

(define-attributes ([l o])  vec3- (x y z)) ; ox oy oz                                          
(define-attributes ([l l.]) vec3- (x y z)) ; l.x l.y l.z                                       
(define-attributes ([l])    vec3- (x y z [length len]))  ; x y z len                           

This does not work. The exception I get is:

; /Users/dstorrs/bmtc_dev/app/test.rkt:11:0: vec3-length: unbound identifier                   
;   in: vec3-length                                                                            
; Context (plain; to see better errortrace context, re-run with C-u prefix):                   
;   /Users/dstorrs/.emacs.d/elpa/racket-mode-20211130.1748/racket/syntax.rkt:66:0    

What am I missing?

1 Like

whoops I didn't add the definition for vec3-length to the documentation which is a normal function accepting a vec3 and returning its length, I will add it to the example.

The function is:

(define (x² x) (* x x))
(define (vec3-length v)
  (define-attributes ([v]) vec3- (x y z))
  (sqrt (+ (x² x) (x² y) (x² z))))

This is simply to illustrate that the "attribute" can also be what I would call a "computed attribute" something you access as if it were an attribute but is actually computed from the value that is being "accessed".


I added it, thank you for letting me know, it should show up in a few hours when the documentation is rebuild.

2 Likes