Printing: written and displayed

->str gives way to two traits, as Scheme's write and display: a value
as the reader reads it, and the value for a person. Every type of your
own is written in constructor syntax. The examples, the REPL session and
the expect output follow.

🤖 Generated with [ECA](https://eca.dev)

Co-Authored-By: eca-agent <git@eca.dev>
This commit is contained in:
Linus Björnstam 2026-10-21 15:38:01 +02:00
parent 481538a1cb
commit e448ee0d93
2 changed files with 107 additions and 49 deletions

View file

@ -104,9 +104,11 @@ Then there are some special cases, like string interpolation:
(a-string "hej")) (a-string "hej"))
(println $"This is interpolated: ${an-int} ${a-string}") (println $"This is interpolated: ${an-int} ${a-string}")
;; Which is literally the same as: ;; Which is literally the same as:
(println (str "This is interpolated: " (->str an-int) " " (->str a-string)))) (println (str "This is interpolated: " (displayed an-int) " " (displayed a-string))))
} }
Each hole goes through @code{displayed}, so a string hole gives its text without quotes. See @em{Converting to and from strings} below.
@subsubsection{Function types} @subsubsection{Function types}
A function takes any number of arguments and produces a value. The signature of a function in bjolang is denoted using the arrow @code{->}: A function takes any number of arguments and produces a value. The signature of a function in bjolang is denoted using the arrow @code{->}:
@ -456,7 +458,7 @@ A few things to know about unions:
@read-list{ @read-list{
- Case names share a namespace with everything else in the module, so two unions in the same module cannot both have a case called @code{Red}. - Case names share a namespace with everything else in the module, so two unions in the same module cannot both have a case called @code{Red}.
- A union case cannot have a @code{#:mutable} field. - A union case cannot have a @code{#:mutable} field.
- @code{println} on a union value without a @code{->str} implementation prints the generated C# name, something like @code{Card { rank = Queen { }, suit = Hearts { } }}. Implement @code{->str} (see the traits section) for anything you intend to print. - @code{println} on a value of your own type prints it as you would build it, for example @code{(Card (rank Queen) (suit Hearts))} or @code{(Pip 7)}. The compiler writes this for each type you declare. To print a type another way, implement @code{Displayed} (see the traits section).
} }
@subsubsection{Types that refer to themselves and each other} @subsubsection{Types that refer to themselves and each other}
@ -543,23 +545,31 @@ The rest of this chapter uses a card game as a running example, so here are its
(: tricks int)))) (: tricks int))))
;; How a card prints. Traits are explained later; for now it is enough ;; How a card prints. Traits are explained later; for now it is enough
;; to know that this is what println and #"...${...}" use. ;; to know that Displayed is what println and #"...${...}" use, and that
(impl (->str Suit) ;; display-into adds the text to the string builder b.
(defun (->str s) (impl (Displayed Suit)
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))) (defun (display-into s b)
(stringbuilder-add-string! b
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))))
(impl (->str Rank) (impl (Displayed Rank)
(defun (->str r) (defun (display-into r b)
(match r (stringbuilder-add-string! b
((Pip n) (int->string n)) (match r
(Jack "J") (Queen "Q") (King "K") (Ace "A")))) ((Pip n) (int->string n))
(Jack "J") (Queen "Q") (King "K") (Ace "A")))))
(impl (->str Card) (impl (Displayed Card)
(defun (->str c) #"${(record-ref c rank)}${(record-ref c suit)}")) (defun (display-into c b)
(display-into (record-ref c rank) b)
(display-into (record-ref c suit) b)))
(println (Card (rank Queen) (suit Hearts))) ;; Q♥ (println (Card (rank Queen) (suit Hearts))) ;; Q♥
(written (Card (rank Queen) (suit Hearts))) ;; "(Card (rank Queen) (suit Hearts))"
}| }|
Without the three implementations, @code{println} prints the card as it is written: @code{(Card (rank Queen) (suit Hearts))}.
@code{Card} is a struct because it is two small values and is copied around a lot. @code{Player} is a record because it holds a whole hand. That is the usual rule of thumb: a struct for something small that behaves like a value, a record for everything else. A struct also cannot contain itself and cannot have mutable fields. @code{Card} is a struct because it is two small values and is copied around a lot. @code{Player} is a record because it holds a whole hand. That is the usual rule of thumb: a struct for something small that behaves like a value, a record for everything else. A struct also cannot contain itself and cannot have mutable fields.
Records are updated by copying, with @code{record-set}: Records are updated by copying, with @code{record-set}:
@ -1926,12 +1936,34 @@ Do not build strings by writing to a string port. Writing to a port is I/O as fa
@subsubsection{Converting to and from strings} @subsubsection{Converting to and from strings}
@code{->str} turns anything into a string, and @code{println} and interpolation use it. It is a trait, so your own types can decide what they look like (the card game did that for @code{Card}). For the basic types there are also named conversions: Two functions turn any value into a string:
@read-list{
- @code{written} gives the value as the reader reads it, as Scheme's @code{write} does. A string is in quotes, a char is @code{#\a}, a number keeps the suffix of its type (@code{21L}), and a double always has a point (@code{3.0}). The REPL shows a result this way.
- @code{displayed} gives the value for a person to read, as Scheme's @code{display} does. A string is its text, and a number has no suffix. @code{println}, @code{print} and interpolation use it.
}
@codeblock[#:lang "bjolang"]|{
(written "hej") ;; "\"hej\""
(displayed "hej") ;; "hej"
(written 21L) ;; "21L"
(displayed 21L) ;; "21"
(written (list "a" "b")) ;; "(\"a\" \"b\")"
(displayed (list "a" "b")) ;; "(a b)"
(written (Some (Tuple 1 "x"))) ;; "(Some (Tuple 1 \"x\"))"
(println (Fruit (name "banana") (amount 5) (ripeness Rotten)))
;; prints (Fruit (name "banana") (amount 5) (ripeness Rotten))
}|
Every type you declare is written as you would build it. A type that prints differently for a person implements @code{Displayed}, as the card game did for @code{Card}; one that is written differently implements @code{Written}. The traits section has more. @code{->str} is deprecated: use @code{displayed}.
For the basic types there are also named conversions:
@codeblock[#:lang "bjolang"]|{ @codeblock[#:lang "bjolang"]|{
(int->string 42) ;; "42" (int->string 42) ;; "42"
(double->string 2.5) ;; "2.5" (double->string 2.5) ;; "2.5"
(->str 3.0) ;; "3" (displayed 3.0) ;; "3"
(written 3.0) ;; "3.0"
(string->int "42") ;; 42, and throws on anything that is not a number (string->int "42") ;; 42, and throws on anything that is not a number
(string->double "1.5") ;; 1.5 (string->double "1.5") ;; 1.5
(char->string #\a) ;; "a" (char->string #\a) ;; "a"
@ -2018,7 +2050,7 @@ Define patterns at the top level, as above, rather than writing @code{#rx(...)}
@subsection{Traits} @subsection{Traits}
A trait is a set of functions that a type can implement. It is how bjolang does what other languages do with interfaces, type classes or overloading: @code{=} is a trait method, so is @code{->str}, and so are @code{length}, @code{fold} and @code{compare}. A trait is a set of functions that a type can implement. It is how bjolang does what other languages do with interfaces, type classes or overloading: @code{=} is a trait method, so is @code{displayed}, and so are @code{length}, @code{fold} and @code{compare}.
@subsubsection{Declaring and implementing a trait} @subsubsection{Declaring and implementing a trait}
@ -2089,7 +2121,7 @@ A generic function that calls a trait method has to say that its type variable i
@code{describe-all} can now be called with a vec of anything that has a @code{Describe}, and calling it with anything else is a compile error at the call. @code{describe-all} can now be called with a vec of anything that has a @code{Describe}, and calling it with anything else is a compile error at the call.
For @code{Eq} and @code{->str} you may leave the @code{where} out: the compiler adds them by itself when a function uses @code{=} or @code{->str} on a type variable. For @code{Eq}, @code{Written} and @code{Displayed} you may leave the @code{where} out: the compiler adds them by itself when a function uses @code{=}, @code{written} or @code{displayed} on a type variable.
@subsubsection{Associated types} @subsubsection{Associated types}
@ -2189,7 +2221,7 @@ A trait may say that every type implementing it implements another trait too, wi
@code{introduce} asks only for @code{Greet}, and may still call @code{name-of}: a type that can greet is known to have a name. An implementation of @code{Greet} for a type with no @code{Named} is a compile error at the implementation. @code{introduce} asks only for @code{Greet}, and may still call @code{name-of}: a type that can greet is known to have a name. An implementation of @code{Greet} for a type with no @code{Named} is a compile error at the implementation.
The prelude's @code{Ord} requires @code{Eq} this way, so a function with @code{(where (Ord %a))} can use @code{=} as well as @code{compare}. The prelude's @code{Ord} requires @code{Eq} this way, so a function with @code{(where (Ord %a))} can use @code{=} as well as @code{compare}. @code{Displayed} requires @code{Written} in the same way.
@subsubsection{Giving a newtype the traits of the type it wraps} @subsubsection{Giving a newtype the traits of the type it wraps}
@ -2216,16 +2248,17 @@ A newtype is the type it wraps when the program runs, so it can borrow that type
A trait that should behave differently is written by hand instead. That is the other use of a newtype: the same data with a different behaviour, such as an @code{Eq} that ignores case on a @code{(Newtype string)}. Sets and maps of the newtype use that implementation, not the wrapped type's. A trait that should behave differently is written by hand instead. That is the other use of a newtype: the same data with a different behaviour, such as an @code{Eq} that ignores case on a @code{(Newtype string)}. Sets and maps of the newtype use that implementation, not the wrapped type's.
@subsubsection{Eq, Ord and ->str} @subsubsection{Eq, Ord, Written and Displayed}
Three traits from the prelude are worth knowing by heart, since the rest of the library leans on them. Four traits from the prelude are worth knowing by heart, since the rest of the library leans on them.
@read-table{ @read-table{
| Trait | Methods | Gives you | | Trait | Methods | Gives you |
|----------------+---------------------------------+------------------------------------------------------------------| |-------------------+-----------------------------------------+------------------------------------------------------------------|
| @code{Eq} | @code{=}, @code{eq-hash} | equality, and use as a key in maps and sets | | @code{Eq} | @code{=}, @code{eq-hash} | equality, and use as a key in maps and sets |
| @code{Ord} | @code{compare} | @code{less?}, @code{least}, @code{list-sort}, @code{list-max}, ordered collections | | @code{Ord} | @code{compare} | @code{less?}, @code{least}, @code{list-sort}, @code{list-max}, ordered collections |
| @code{->str} | @code{->str} | @code{println}, @code{print}, string interpolation | | @code{Written} | @code{write-into}, @code{written} | the REPL's output, the failure text of @code{expect} |
| @code{Displayed} | @code{display-into}, @code{displayed} | @code{println}, @code{print}, string interpolation |
} }
@code{Eq} and @code{Ord} can be derived with @code{type/derive}, as shown earlier. When you write them by hand, they must be written in the module that declares the type, because they are compiled into the type itself: they become its .NET @code{Equals}, @code{GetHashCode} and @code{CompareTo}, which is why a .NET dictionary or a sorted collection agrees with @code{=} and @code{compare}. @code{=} and @code{eq-hash} must agree: two values that are equal must have the same hash. @code{compare} returns a negative number, zero or a positive number. @code{Eq} and @code{Ord} can be derived with @code{type/derive}, as shown earlier. When you write them by hand, they must be written in the module that declares the type, because they are compiled into the type itself: they become its .NET @code{Equals}, @code{GetHashCode} and @code{CompareTo}, which is why a .NET dictionary or a sorted collection agrees with @code{=} and @code{compare}. @code{=} and @code{eq-hash} must agree: two values that are equal must have the same hash. @code{compare} returns a negative number, zero or a positive number.
@ -2239,19 +2272,36 @@ Three traits from the prelude are worth knowing by heart, since the rest of the
(hash-combine (eq-hash (record-ref c rank)) (eq-hash (record-ref c suit))))) (hash-combine (eq-hash (record-ref c rank)) (eq-hash (record-ref c suit)))))
}| }|
@code{->str} has a blanket implementation that uses .NET's @code{ToString}, which is why printing one of your own types shows its C# name until you implement it. The compiler writes a @code{Written} for every type you declare, so a value prints as you would build it: @code{(Card (rank Queen) (suit Hearts))}, @code{(Pip 7)}, @code{Hearts}. A generic type is written when its type arguments are. An @code{#:opaque} type is written as @code{#<Name>}, and a field that holds a function as @code{#<procedure>}. @code{Displayed} has a blanket implementation that displays a value as it is written.
To print a type your own way, implement @code{Displayed} for the text a person reads, as the card game did, or @code{Written} to change the written form too. The method to give is @code{display-into} (or @code{write-into}), which adds the text to a string builder; @code{displayed} and @code{written} then come with the trait:
@codeblock[#:lang "bjolang"]|{
(type (: Money (Union (: Kr int))))
(impl (Displayed Money)
(defun (display-into m b)
(match m ((Kr n) (stringbuilder-add-string! b #"${n} kr")))))
(println (Kr 42)) ;; 42 kr
(written (Kr 42)) ;; "(Kr 42)"
}|
A container that holds other values calls @code{write-into} or @code{display-into} on them with the same builder. @code{elements-print-into} and @code{entries-print-into} from the prelude lay out the elements of a collection, or the entries of a map, for you.
@code{->str} is deprecated. It still works, through @code{displayed}, but each use gives a warning.
@subsubsection{Values of different types in one collection: dyn} @subsubsection{Values of different types in one collection: dyn}
A list holds values of one type. When you need a collection of different types that all implement the same trait, pack each value as a @code{(dyn Trait)}: A list holds values of one type. When you need a collection of different types that all implement the same trait, pack each value as a @code{(dyn Trait)}:
@codeblock[#:lang "bjolang"]|{ @codeblock[#:lang "bjolang"]|{
(: render-all (-> (List (dyn ->str)) (List string))) (: render-all (-> (List (dyn Displayed)) (List string)))
(defun (render-all xs) (list-map ->str xs)) (defun (render-all xs) (list-map displayed xs))
(render-all (list (dyn ->str 1) (render-all (list (dyn Displayed 1)
(dyn ->str "two") (dyn Displayed "two")
(dyn ->str (Card (rank Queen) (suit Hearts))))) (dyn Displayed (Card (rank Queen) (suit Hearts)))))
;; '("1" "two" "Q♥") ;; '("1" "two" "Q♥")
}| }|

View file

@ -22,18 +22,22 @@ Here is the deck from the first part as a module of its own. Put it in @code{dec
(: Rank (Union (: Pip int) Jack Queen King Ace)) (: Rank (Union (: Pip int) Jack Queen King Ace))
(: Card (Struct (: rank Rank) (: suit Suit)))) (: Card (Struct (: rank Rank) (: suit Suit))))
(impl (->str Suit) (impl (Displayed Suit)
(defun (->str s) (defun (display-into s b)
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))) (stringbuilder-add-string! b
(match s (Clubs "♣") (Diamonds "♦") (Hearts "♥") (Spades "♠")))))
(impl (->str Rank) (impl (Displayed Rank)
(defun (->str r) (defun (display-into r b)
(match r (stringbuilder-add-string! b
((Pip n) (int->string n)) (match r
(Jack "J") (Queen "Q") (King "K") (Ace "A")))) ((Pip n) (int->string n))
(Jack "J") (Queen "Q") (King "K") (Ace "A")))))
(impl (->str Card) (impl (Displayed Card)
(defun (->str c) #"${(record-ref c rank)}${(record-ref c suit)}")) (defun (display-into c b)
(display-into (record-ref c rank) b)
(display-into (record-ref c suit) b)))
;; Not exported: only this module needs them. ;; Not exported: only this module needs them.
(def all-suits [Clubs Diamonds Hearts Spades]) (def all-suits [Clubs Diamonds Hearts Spades])
@ -78,7 +82,7 @@ Notice how the imports and exports interact here:
@read-list{ @read-list{
- Relative imports: @code{(import "deck.bjo")} specifies a file path relative to the importing file. Bjolang compiles @code{deck.bjo} into @code{deck.dll} ahead of time, automatically rebuilding it whenever it (or any of its dependencies) changes. - Relative imports: @code{(import "deck.bjo")} specifies a file path relative to the importing file. Bjolang compiles @code{deck.bjo} into @code{deck.dll} ahead of time, automatically rebuilding it whenever it (or any of its dependencies) changes.
- Type exports are complete: Exporting a type exposes all its constructors, union cases, and fields. Because @code{Card} is exported, @code{game.bjo} can freely construct @code{(Card (rank Ace) (suit Spades))} and pattern-match on its fields. - Type exports are complete: Exporting a type exposes all its constructors, union cases, and fields. Because @code{Card} is exported, @code{game.bjo} can freely construct @code{(Card (rank Ace) (suit Spades))} and pattern-match on its fields.
- Trait implementations are automatic: Although @code{->str} was not explicitly listed in the export list, its implementations still work in @code{game.bjo}. Because @code{impl} blocks define anonymous implementations rather than named bindings, they cannot be exported individually; they are automatically carried along whenever the module defining them is loaded. - Trait implementations are automatic: Although @code{Displayed} was not explicitly listed in the export list, its implementations still work in @code{game.bjo}. Because @code{impl} blocks define anonymous implementations rather than named bindings, they cannot be exported individually; they are automatically carried along whenever the module defining them is loaded.
- Private definitions: Unexported bindings like @code{all-suits} remain private to @code{deck.bjo} and are inaccessible from outside. - Private definitions: Unexported bindings like @code{all-suits} remain private to @code{deck.bjo} and are inaccessible from outside.
} }
@ -148,7 +152,7 @@ so its representation is visible only to the code of pile. A value of it can be
passed on here, and built and taken apart through the functions that module exports. passed on here, and built and taken apart through the functions that module exports.
}| }|
Even though the type's representation is private, any trait implementations defined in @code{pile.bjo} remain available to consumers. For instance, an @code{(impl (->str Pile) ...)} inside @code{pile.bjo} allows any importing module to convert a @code{Pile} to a string. Even though the type's representation is private, any trait implementations defined in @code{pile.bjo} remain available to consumers. For instance, an @code{(impl (Displayed Pile) ...)} inside @code{pile.bjo} allows any importing module to print a @code{Pile}. Without one, a @code{Pile} is written as @code{#<Pile>}, so printing it shows nothing of its representation.
@subsubsection{Import modifiers} @subsubsection{Import modifiers}
@ -221,7 +225,7 @@ While @code{export} publishes definitions authored in the current module, @code{
(re-export Card Rank Suit full-deck shuffle Pile new-pile draw) (re-export Card Rank Suit full-deck shuffle Pile new-pile draw)
}| }|
Re-exporting a type exposes the exact original type, not a distinct copy; a @code{Card} referenced via @code{cards.bjo} is identical to one from @code{deck.bjo}. However, trait implementations (@code{impl}) do not transfer through re-exports. They remain bound to the module where they were defined, meaning any file that wants to format cards with @code{->str} must still import @code{deck.bjo}. Re-exporting a type exposes the exact original type, not a distinct copy; a @code{Card} referenced via @code{cards.bjo} is identical to one from @code{deck.bjo}. However, trait implementations (@code{impl}) do not transfer through re-exports. They remain bound to the module where they were defined, meaning any file that wants to print cards with their @code{Displayed} implementation must still import @code{deck.bjo}.
You can provide alternative names for bindings or macros using @code{(:alias new old)}. Combining @code{:alias} with @code{export} or @code{re-export} allows libraries to offer cleaner or more idiomatic public APIs: You can provide alternative names for bindings or macros using @code{(:alias new old)}. Combining @code{:alias} with @code{export} or @code{re-export} allows libraries to offer cleaner or more idiomatic public APIs:
@ -471,7 +475,7 @@ To illustrate, we can add a persistent high-score ledger to our card game. Each
(defun (main) (defun (main)
(save-score "ada" 31) (save-score "ada" 31)
(save-score "bo" 27) (save-score "bo" 27)
(println (read-scores)) ;; [(ada, 31) (bo, 27)] (println (read-scores)) ;; [(Tuple ada 31) (Tuple bo 27)]
0) 0)
}| }|
@ -1730,7 +1734,7 @@ Invalid literal syntax is flagged with a compile-time error at the point of use:
The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts The hash macro 'card' failed at bad.bjo:3: a suit is clubs, diamonds, hearts or spades — in hurts
}| }|
Notice the @code{re-export} in @code{cardmacros.bjo}: because the macro expansion emits constructor symbols like @code{Card} and @code{Queen}, those types must be resolvable in the module invoking the macro. Re-exporting them ensures that importing @code{cardmacros.bjo} alone brings the required types into scope. Importing @code{deck.bjo} alongside it remains recommended so that the corresponding @code{->str} implementations are also present. Notice the @code{re-export} in @code{cardmacros.bjo}: because the macro expansion emits constructor symbols like @code{Card} and @code{Queen}, those types must be resolvable in the module invoking the macro. Re-exporting them ensures that importing @code{cardmacros.bjo} alone brings the required types into scope. Importing @code{deck.bjo} alongside it remains recommended so that the corresponding @code{Displayed} implementations are also present.
@subsubsection{Literals read by a grammar} @subsubsection{Literals read by a grammar}
@ -1850,7 +1854,7 @@ ok: an ace is worth 11
ok: shuffling keeps the cards ok: shuffling keeps the cards
}| }|
Whenever an assertion fails, the output reports both the computed value and the expected value: @code{FAILURE: deliberately wrong gave 2 but wanted 3}. Whenever an assertion fails, the output reports both the computed value and the expected value, each as @code{written} gives it: @code{FAILURE: deliberately wrong gave 2 but wanted 3}, or @code{gave "4" but wanted "5"} for strings. So @code{expect} needs a @code{Written} for the type, which every type has.
For asynchronous or resource-intensive testing, @code{(with-test "name" body ...)} executes the test body within an isolated structured concurrency scope configured with a default deadline. Any background fibers spawned during the test are automatically awaited, and the test fails if unclosed file ports remain open when the scope exits. Because @code{with-test} forms a concurrency scope, it must be invoked from within a bjoroutine. For asynchronous or resource-intensive testing, @code{(with-test "name" body ...)} executes the test body within an isolated structured concurrency scope configured with a default deadline. Any background fibers spawned during the test are automatically awaited, and the test fails if unclosed file ports remain open when the scope exits. Because @code{with-test} forms a concurrency scope, it must be invoked from within a bjoroutine.
@ -1885,14 +1889,18 @@ You can launch an interactive Read-Eval-Print Loop by running @code{bjo repl}. W
bjo> (+ 1 2) bjo> (+ 1 2)
3 3
bjo> (import "deck.bjo") bjo> (import "deck.bjo")
bjo> (vec-slice (full-deck) 0 3) bjo> (vec-slice (full-deck) 0 2)
[2♣ 3♣ 4♣] [(Card (rank (Pip 2)) (suit Clubs)) (Card (rank (Pip 3)) (suit Clubs))]
bjo> (displayed (vec-slice (full-deck) 0 3))
"[2♣ 3♣ 4♣]"
bjo> (defun (double (: x int)) : int (* x 2)) bjo> (defun (double (: x int)) : int (* x 2))
double double
bjo> (double 21) bjo> (double 21)
42 42
}| }|
The REPL shows a result with @code{written}, as the reader reads it: a string in quotes, a card as it is built. @code{println} and @code{displayed} give the text a person reads.
Unlike many Lisp REPLs, Bjolang's REPL does not interpret forms. Instead, each entered interaction is compiled on the fly into an in-memory assembly using the exact same compiler pipeline applied to files on disk, ensuring identical semantics between interactive sessions and compiled production builds. Unlike many Lisp REPLs, Bjolang's REPL does not interpret forms. Instead, each entered interaction is compiled on the fly into an in-memory assembly using the exact same compiler pipeline applied to files on disk, ensuring identical semantics between interactive sessions and compiled production builds.
Top-level functions declared in the REPL require explicit type signatures, mirroring the rules for modules. You can supply parameter and return types inline directly on the @code{defun}, or submit a standalone signature on its own line immediately preceding the definition: Top-level functions declared in the REPL require explicit type signatures, mirroring the rules for modules. You can supply parameter and return types inline directly on the @code{defun}, or submit a standalone signature on its own line immediately preceding the definition: