Sanity check regarding #:transparent changing the value of an expression

I was recently hacking away on a function in FSM and found that adding or removing #:transparent from a struct definition caused the expression to evaluate to a different value. Without it, it goes into an infinite loop from what appears to be the set-member? within the when clause not functioning correctly, with it, it terminates to a value correctly.

I was hoping to see if anyone could notice anything that would cause this behavior or maybe there are semantics I’m not aware of regarding using #:transparent?

I extracted the code here, apologies for such a large reproduction example. Any smaller example I attempted to create for this failed to reproduce the issue:

#lang racket/base
(require racket/set
         data/queue
         racket/list)

;; Remove transparency to change value of the expression
(struct pda-stack (elems len) #:transparent)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(struct pda-config (state ci-len stack) #:transparent)
(struct pda-rule (from-state read-symb pop to-state push push-len pop-len) #:transparent)
(define EMP 'ε)

(define (apply-ndpda K sigma gamma start finals pdarules w)
  (define visited-set (mutable-set))
  (define tovisit (make-queue))
  (define finals-set (list->seteq finals))
  (define word-vec (list->vector w))
  (define rules-structs
    (for/list ([pdarule (in-list pdarules)])
      (pda-rule (caar pdarule)
                (cadar pdarule)
                (if (eq? (caddar pdarule) EMP)
                    '()
                    (caddar pdarule))
                (caadr pdarule)
                (if (eq? (cadadr pdarule) EMP)
                    '()
                    (cadadr pdarule))
                (if (eq? (cadadr pdarule) EMP)
                    0
                    (length (cadadr pdarule)))
                (if (eq? (caddar pdarule) EMP)
                    0
                    (length (caddar pdarule))))))
    
  (define (mk-pdatransition r c)
    (pda-config (pda-rule-to-state r)
                (if (eq? (pda-rule-read-symb r) EMP)
                    (pda-config-ci-len c)
                    (+ (pda-config-ci-len c) 1))
                (pda-stack (append (pda-rule-push r) (drop (pda-stack-elems (pda-config-stack c)) (pda-rule-pop-len r)))
                           (+ (pda-stack-len (pda-config-stack c))
                              (- (pda-rule-push-len r)
                                 (pda-rule-pop-len r))))))
    
  (define (loop)
    (cond [(queue-empty? tovisit) 'reject]
          [else
           (define config (dequeue! tovisit))
           (define new-configs
             (for/list ([rule (in-list rules-structs)]
                        #:when (and (eq? (pda-config-state config) (pda-rule-from-state rule))
                                    (or (eq? EMP (pda-rule-read-symb rule))
                                        (and (< (pda-config-ci-len config) (vector-length word-vec))
                                             (eq? (vector-ref word-vec (pda-config-ci-len config))
                                                  (pda-rule-read-symb rule))))
                                    (or (and (>= (pda-stack-len (pda-config-stack config)) (pda-rule-pop-len rule))
                                             (equal? (pda-rule-pop rule)
                                                     (take (pda-stack-elems (pda-config-stack config))
                                                           (pda-rule-pop-len rule))))))
                        #:do [(define new-config (mk-pdatransition rule config))]
                        #:when (not (set-member? visited-set new-config)))
               (set-add! visited-set new-config)
               new-config))
           (define accepts
             (filter (lambda (c) (and (set-member? finals-set (pda-config-state c))
                                      (= 0 (pda-stack-len (pda-config-stack c)))
                                      (= (vector-length word-vec) (pda-config-ci-len c))))    
                     (cons config new-configs)))
           (cond [(not (null? accepts))
                  'accept]
                 [else
                  (for ([new-config (in-list new-configs)])
                    (enqueue! tovisit new-config))
                  (loop)])]))
  (enqueue! tovisit (pda-config start 0 (pda-stack '() 0)))
  (set-add! visited-set (pda-config start 0 (pda-stack '() 0)))
  (loop))

(apply-ndpda '(s S M F)
             '(a b)
             '(a b)
             's
             '(s F)
             '(((s ε ε) (S ε))
               ((F ε ε) (S ε))
               ((S ε ε) (M ε))
               ((M a ε) (M (a)))
               ((M b (a)) (M ε))
               ((M a (b)) (M ε))
               ((M b ε) (M (b)))
               ((M ε ε) (F ε)))
             '(a))

In general,

ransparent struct-type definitions enable equal? and other Racket functions to traverse struct instances. By contrast, an opaque struct-type definition forbids functions from extracting value from inside of an instance unless you provide them with the accessor function directly. This gives you some kind of privacy guarantees.

Consider this program:

#lang racket/base

(struct moo (cow) #; #:transparent)

(define x (moo 1))
(define y (moo 1))

(equal? x y)

Run it and you get #false.

Now remove the comment character inside of the (struct ...) definition.
Run it and you get #true.

;; - - -

In particular,

Since your mutable-set uses equal? to compare values (that's what set-equal? in the docs mean), your program sometimes can't find an element that is supposed to be there. As a result it continues to traverse cycles in your FSM.

You can run your program like this, with one of the #:transparent removed:

; Remove transparency to change value of the expression
(struct pda-stack (elems len) #:transparent)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(struct pda-config (state ci-len stack) #:transparent)
(struct pda-rule (from-state read-symb pop to-state push push-len pop-len))

;; - - -

Question:

What would like to achieve with the removal of #:transparent?

I was completely unaware of this, I had thought that equal? would check for extensional equality regardless of whether the structure was opaque or not. I have reading to do on inspectors and inspectability it seems.

I have no need to remove transparent, I initially had only added it to aid in debugging and noticed that I was getting wildly different results, which made absolutely zero sense with the mental model I had before.

Thank you for pointing this out, because equal? would have likely been the last place I’d search trying to figure out what I was doing incorrectly

Would it make sense to add an example similar to

#lang racket/base

(struct moo (cow) #; #:transparent)

(define x (moo 1))
(define y (moo 1))

(equal? x y)

to the documentation for equal? ? This seems like a useful example to include there

I think so. This is a classic "new to Racket structs" stumbling block,
and I can't actually find documented for structs or struct inspectors
what "transparent" means other than "inspector = #f" which is
described as… "transparent."

Docs starting from

seem relevant, but are only part of the guide and only mentioned in
the reference via the "Programmer-Defined Datatypes in The Racket
Guide introduces structure types via struct." tip at the top of the
page, which probably doesn't suggest that one should go there to find
out what transparency means.

On Mar 31, 2026, at 2:43 PM, Andres Garced asked whether it would "make sense to add an example similar to … the documentation for equal? ? This seems like a useful example to include there".

An example like that is located right there: Equality of Structures

Perhaps a cross-link to this example from equal? might help (@mflatt )