Structs and Keywords

Coming back to learning Racket after a bit of a pause---I've just realized that Racket does not seem to have native support for keyword assignment in structs. Is that still the case? If so, I've read old blog posts about using macros to support this, and there seems to be the struct++ library that adds support for this. Anyway, I'm just wondering whether there are plans to add this to the base language? If not, are there specific reasons not to support this? Would it be safe to rely on custom macros or the struct++ library in this case? Or might that approach break with later updates to the core language?

3 Likes

I am a bit unsure what "keyword assignments" mean.
Do you have a small example?

From the blog post linked above. Instead of using per-position assignment:

(foo 10     ;a
     "foo"  ;b
     13     ;c
     "bar"  ;d
     "baz"  ;e
     #f     ;f
     "x"    ;g
     42)

I'd like to use this:

(foo #:a 10
     #:b "foo"
     #:c 13
     #:d "bar"
     #:e "baz"
     #:f #f
     #:g "x"
     #:h 42)
1 Like

I think struct++ is a good fit then.

1 Like

Inlay hints can also fix this issue without language modifications! So if anyone knows how LSP works, please try and implement it, thanks.

2 Likes

If you have any appetite for roll-your-own macros, here's an outline of another approach. It looks long, but it's mostly tests.

#lang racket

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

(module+ test
  (require rackunit))

;;
;; Struct+
;;
(define-syntax (struct+ stx)

  ;; a syntax class for a struct field that associates a default or bespoke keyword 
  (define-syntax-class field
    (pattern (~or* [id:id kw-arg:keyword]
                   id:id)

             #:with kw
             (or (attribute kw-arg)
                 (string->keyword (symbol->string (syntax->datum (attribute id)))))
             
             #:with kw-header
             #`(kw id)))

  ;; The macro that creates a standard struct and a procedure to build it from keywords
  (syntax-parse stx
    [(_ struct-id:id
        [field:field ...+]
        (~optional (~seq #:new-name new-name)))

     #:fail-when (check-duplicate-identifier (syntax->list #'(field.id ...)))
     "duplicate field"

     #:with new-id
     (or (attribute new-name)
         (format-id (attribute struct-id) "new-~a" (attribute struct-id)))

     #`(begin
         (struct struct-id
           [field.id ...])

         (define (new-id (~@ . field.kw-header) ...)
           (struct-id field.id ...)))]))

;;
;; Tests
;;

(module+ test
  (struct+ tester
           [field-1
            [field-2 #:bespoke-keyword]])
  
  (define t
    (new-tester #:field-1 "foo"
                #:bespoke-keyword "bar"))

  (check-equal? (tester-field-1 t)
                "foo")

  (check-equal? (tester-field-2 t)
                "bar")

  (struct+ tester-2
           [foo
            bar]
           #:new-name bespoke-name-for-new)
  
  (define s
    (bespoke-name-for-new #:foo "hello"
                          #:bar "world"))

  (check-equal? (tester-2-foo s)
                "hello")

  (check-equal? (tester-2-bar s)
                "world")

  (struct+ foo
           [a
            b
            c
            d
            e
            f
            g
            h])
  
  (define f
    (new-foo #:a 10
             #:b "foo"
             #:c 13
             #:d "bar"
             #:e "baz"
             #:f #f
             #:g "x"
             #:h 42))
  
  (check-equal? (foo-a f)
                10)
  (check-equal? (foo-b f)
                "foo")
  (check-equal? (foo-c f)
                13)
  (check-equal? (foo-d f)
                "bar")
  (check-equal? (foo-e f)
                "baz")
  (check-equal? (foo-f f)
                #f)
  (check-equal? (foo-g f)
                "x")
  (check-equal? (foo-h f)
                42))

As only a minor improvement, note that you can also write #; comments, which makes it look more like keywords, and works with one liners:

(foo #;a 10 #;b "foo" #;c 13)

(except of course for Discourse which doesn't handle this type of comments.)

Thanks for all the very helpful answers. Seems there are a couple of ways to support this---which is good to know.

Does anyone know why this is not a standard feature?

On the one hand Racket aims to be "batteries included". Of course, different people, writing different kinds of programs, can have very different ideas what specific set of batteries would be ideal for them.

On the other hand, Racket macros mean that, if a "battery" isn't standard, you can almost always make it yourself -- including even features that would need to be built into, and require an updated version of, most other programming languages. The self-service lane is always open.

For example I don't tend to write many programs that use many "fat" structs; not that it's wrong to do so, I just haven't happened to be doing things for problem spaces where I need that very often. When I do need such a struct, I'm fine writing a custom constructor "by hand", which takes keyword arguments. If I needed it more often, for some project, I'd be OK writing the sort of little definer macro I outlined in that old blog most of mine that you linked to. Or using a package like struct++.

So I don't have a good answer for why this specific thing isn't standard in Racket, at least not yet. But I think the good news is, Racket in general has a wonderful answer, which is that you're empowered to add the missing thing, in a way that you might be unable to do in many languages.

2 Likes

Partly it is historical. Racket evolved from Scheme which has neither structures/records nor keywords. Most Scheme implementations had nevertheless structures in some form, so a design was made without keywords.

It was relatively late that keywords were introduced in Racket:

Keyword and Optional Arguments in PLT Scheme, Flatt and Barzilay

This doesn't answer the obvious question: why wasn't the strucure constructors updated to allow keyword arguments?

There are several factors, but I believe performance might have been an important one.
Calling a function with keyword arguments are slower than calling an function that doesn't allow keywords. Some of the bookkeeping needs to happen at runtime. This is in contrast to statically typed languages, which can handle this at compile time.

So in principle, this should be possible at compile time in typed Racket.

-- hendrik

2 Likes