From 43e717f2909d79ea7148e5a6e3b7ea2173f31955 Mon Sep 17 00:00:00 2001 From: Veit Heller Date: Thu, 17 Sep 2026 21:00:56 +0200 Subject: [PATCH] 0.3.0: into-string, prn, and documented automatic buffer ownership --- README.md | 44 +++++++++++---- gendocs.carp | 14 ++--- src/strbuf.h | 8 +++ strbuf.carp | 32 +++++++++-- test/strbuf.carp | 135 +++++++++++++++++++++++++++++++++++++---------- 5 files changed, 184 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 694ea9d..5853f59 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A growable string buffer for Carp. ## Installation ```clojure -(load "git@github.com:carpentry-org/strbuf@0.2.1") +(load "git@github.com:carpentry-org/strbuf@0.3.0") ``` ## Usage @@ -20,12 +20,36 @@ allocates a new string on every call. (StringBuf.append-int &sb 200) (StringBuf.append-str &sb " OK") (StringBuf.append-crlf &sb) - (println* &(StringBuf.to-string &sb)) - (StringBuf.delete sb))) + (println* &(StringBuf.str &sb)))) ``` -`to-string` returns the contents and resets the buffer for reuse. -`str` copies the contents without resetting. +## Ownership + +Buffers follow Carp's memory model like any other value. An owned `StringBuf` +is deleted when it goes out of scope, no matter where it lives: in a `let`, in +an array, inside a struct or sum type, returned from a function, or moved into +one. `@` copies a buffer, and the copy is freed independently. You never call +`StringBuf.delete` yourself; it is there because it implements the `delete` +interface that the compiler calls for you. + +Three functions hand you a `String`, and they differ in what happens to the +buffer: + +- `str` copies the contents and leaves the buffer untouched +- `to-string` copies the contents and resets the buffer for reuse +- `into-string` consumes the buffer and hands its allocation to the `String`, + so nothing is copied and nothing is freed + +Use `into-string` when you build a string once and are done with the buffer, +and `to-string` when you keep appending to the same buffer afterwards. + +```clojure +(defn greeting [name] + (let-do [sb (StringBuf.create)] + (StringBuf.append-str &sb "hello, ") + (StringBuf.append-str &sb name) + (StringBuf.into-string sb))) +``` ## API @@ -34,13 +58,15 @@ allocates a new string on every call. - `StringBuf.append-char` — append a single character - `StringBuf.append-bytes` — append raw bytes - `StringBuf.append-int` — append integer as decimal -- `StringBuf.append-double` — append double as string +- `StringBuf.append-long` — append long as decimal +- `StringBuf.append-double` / `StringBuf.append-float` — append as string +- `StringBuf.append-bool` — append `true` or `false` - `StringBuf.append-crlf` — append `\r\n` - `StringBuf.length` — current byte count -- `StringBuf.to-string` — extract as String, reset buffer -- `StringBuf.str` — copy as String, keep buffer +- `StringBuf.str` / `StringBuf.prn` — copy as String, keep the buffer as is +- `StringBuf.to-string` — copy as String, reset the buffer +- `StringBuf.into-string` — consume the buffer, no copy - `StringBuf.clear` — reset without freeing -- `StringBuf.delete` — free the buffer ## Testing diff --git a/gendocs.carp b/gendocs.carp index e02c771..f45cb87 100644 --- a/gendocs.carp +++ b/gendocs.carp @@ -8,14 +8,16 @@ (Project.config "docs-prelude" "A growable string buffer for Carp. Provides amortized O(1) appends, unlike `String.append` which allocates on every call. +Buffers are managed by Carp: an owned `StringBuf` is deleted when it goes out +of scope, so `delete` is never called by hand. + ### Usage ``` -(let [sb (StringBuf.create)] - (do - (StringBuf.append-str &sb \"hello \") - (StringBuf.append-int &sb 42) - (println* &(StringBuf.to-string &sb)) - (StringBuf.delete sb))) +(defn greeting [name] + (let-do [sb (StringBuf.create)] + (StringBuf.append-str &sb \"hello, \") + (StringBuf.append-str &sb name) + (StringBuf.into-string sb))) ```") (save-docs StringBuf) diff --git a/src/strbuf.h b/src/strbuf.h index 9e80020..dd71069 100644 --- a/src/strbuf.h +++ b/src/strbuf.h @@ -129,6 +129,14 @@ String StringBuf_str(StringBuf* sb) { return s; } +/* Consumes the buffer and hands its allocation over to the returned String. + No copy is made and nothing is freed: the String owns the memory now. */ +String StringBuf_into_MINUS_string(StringBuf sb) { + strbuf_grow(&sb, 1); + sb.data[sb.len] = '\0'; + return sb.data; +} + void StringBuf_clear(StringBuf* sb) { sb->len = 0; } diff --git a/strbuf.carp b/strbuf.carp index f6287c9..d5317eb 100644 --- a/strbuf.carp +++ b/strbuf.carp @@ -6,7 +6,7 @@ amortized O(1) appends. ## Installation ``` -(load \"git@github.com:carpentry-org/strbuf@0.2.1\") +(load \"git@github.com:carpentry-org/strbuf@0.3.0\") ``` ## Usage @@ -17,9 +17,19 @@ amortized O(1) appends. (StringBuf.append-str &sb \"hello\") (StringBuf.append-char &sb \\space) (StringBuf.append-str &sb \"world\") - (println* &(StringBuf.to-string &sb)) - (StringBuf.delete sb))) -```") + (println* &(StringBuf.str &sb)))) +``` + +## Ownership + +Buffers are managed like any other Carp value: an owned `StringBuf` is +deleted when it goes out of scope, copied by `@`, and moved when it is +passed on. You never call `StringBuf.delete` yourself. + +To get a `String` out of a buffer, pick by what you want to happen to the +buffer: `str` copies the contents and leaves the buffer alone, `to-string` +copies them and resets the buffer for reuse, and `into-string` consumes the +buffer and hands its allocation to the `String` without copying.") (relative-include "src/strbuf.h") (register-type StringBuf) @@ -66,15 +76,27 @@ amortized O(1) appends. the buffer to empty. The buffer's allocation is kept for reuse.") (register to-string (Fn [&StringBuf] String)) + (doc into-string "consumes the buffer and returns its contents as an owned +String. The buffer's allocation is handed over to the String, so nothing is +copied and nothing is freed.") + (register into-string (Fn [StringBuf] String)) + (doc str "returns a copy of the buffer contents as a String without modifying the buffer.") (register str (Fn [&StringBuf] String)) (implements str StringBuf.str) + (doc prn "returns a copy of the buffer contents as a String without +modifying the buffer.") + (defn prn [sb] (StringBuf.str sb)) + (implements prn StringBuf.prn) + (doc clear "resets the buffer to empty without freeing memory.") (register clear (Fn [&StringBuf] ())) - (doc delete "frees the buffer.") + (doc delete "frees the buffer. This implements the `delete` interface, which +means Carp calls it for you when an owned buffer goes out of scope; you should +not need to call it directly.") (register delete (Fn [StringBuf] ())) (implements delete StringBuf.delete) diff --git a/test/strbuf.carp b/test/strbuf.carp index efe2b83..a948ce9 100644 --- a/test/strbuf.carp +++ b/test/strbuf.carp @@ -2,12 +2,19 @@ (load "../strbuf.carp") (use Test) +; a buffer that is built, handed back, and never deleted by hand +(defn build [s] (let-do [sb (StringBuf.create)] (StringBuf.append-str &sb s) sb)) + +(defn consume [sb] (StringBuf.length &sb)) + +(deftype Holder [buf StringBuf]) + (deftest test (assert-equal test "hello" &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "hello") - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-str and to-string") (assert-equal test @@ -16,7 +23,7 @@ (StringBuf.append-char &sb \a) (StringBuf.append-char &sb \b) (StringBuf.append-char &sb \c) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-char builds string") (assert-equal test @@ -24,7 +31,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "n=") (StringBuf.append-int &sb 42) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-int") (assert-equal test @@ -32,20 +39,19 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "ok") (StringBuf.append-crlf &sb) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-crlf") (assert-equal test 0 - (let [sb (StringBuf.create)] - (let-do [l (StringBuf.length &sb)] (StringBuf.delete sb) l)) + (StringBuf.length &(StringBuf.create)) "empty buffer has length 0") (assert-equal test 5 (let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "hello") - (let-do [l (StringBuf.length &sb)] (StringBuf.delete sb) l)) + (StringBuf.length &sb)) "length after append") (assert-equal test @@ -53,7 +59,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "hello") (StringBuf.clear &sb) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "clear resets to empty") (assert-equal test @@ -61,10 +67,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "hello") (let-do [s1 (StringBuf.str &sb) - s2 (StringBuf.str &sb)] - (StringBuf.delete sb) - (ignore s2) - s1)) + s2 (StringBuf.str &sb)] (ignore s2) s1)) "str copies without consuming") (assert-equal test @@ -73,7 +76,7 @@ (StringBuf.append-str &sb "first") (ignore (StringBuf.to-string &sb)) (StringBuf.append-str &sb "second") - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "to-string resets, buffer is reusable") ; Test large append (triggers reallocation) @@ -81,7 +84,7 @@ 1000 (let-do [sb (StringBuf.create)] (for [i 0 1000] (StringBuf.append-char &sb \x)) - (let-do [l (StringBuf.length &sb)] (StringBuf.delete sb) l)) + (StringBuf.length &sb)) "1000 char appends") (assert-equal test @@ -89,7 +92,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "pi=") (StringBuf.append-double &sb 3.14) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-double") (assert-equal test @@ -97,7 +100,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "n=") (StringBuf.append-long &sb 42l) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-long") (assert-equal test @@ -105,7 +108,7 @@ &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "x=") (StringBuf.append-float &sb 1.5f) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-float") (assert-equal test @@ -115,7 +118,7 @@ (StringBuf.append-bool &sb true) (StringBuf.append-str &sb " no=") (StringBuf.append-bool &sb false) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-bool") (assert-equal test @@ -123,44 +126,118 @@ &(let-do [sb (StringBuf.create) bytes [(Byte.from-int 97) (Byte.from-int 98) (Byte.from-int 99)]] (StringBuf.append-bytes &sb &bytes) - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "append-bytes") (assert-equal test "hello" &(let-do [sb (StringBuf.create)] (StringBuf.append-str &sb "hello") - (let-do [sb2 (StringBuf.copy &sb) - s (StringBuf.to-string &sb2)] - (StringBuf.delete sb) - (StringBuf.delete sb2) - s)) + (let [sb2 @&sb] (StringBuf.to-string &sb2))) "copy produces independent clone") (assert-equal test "hello" &(let-do [sb (StringBuf.with-capacity 4)] (StringBuf.append-str &sb "hello") - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "with-capacity grows past initial capacity") (assert-equal test 0 - (let [sb (StringBuf.with-capacity 0)] - (let-do [l (StringBuf.length &sb)] (StringBuf.delete sb) l)) + (StringBuf.length &(StringBuf.with-capacity 0)) "with-capacity 0 yields an empty buffer") (assert-equal test "hello" &(let-do [sb (StringBuf.with-capacity 0)] (StringBuf.append-str &sb "hello") - (let-do [s (StringBuf.to-string &sb)] (StringBuf.delete sb) s)) + (StringBuf.to-string &sb)) "with-capacity 0 buffer grows and is usable") + (assert-equal test + "hello" + &(StringBuf.into-string (build "hello")) + "into-string returns the contents") + + (assert-equal test + "" + &(StringBuf.into-string (StringBuf.create)) + "into-string on an empty buffer") + + (assert-equal test + "full" + &(let-do [sb (StringBuf.with-capacity 4)] + (StringBuf.append-str &sb "full") + (StringBuf.into-string sb)) + "into-string terminates a buffer that is exactly full") + + (assert-equal test + "hello" + &(StringBuf.prn &(build "hello")) + "prn shows the buffer contents") + + ; Ownership: the compiler deletes buffers for us, in every position (assert-equal test 0l (do (Debug.reset-memory-balance!) (let [_sb (StringBuf.create)] ()) (Debug.memory-balance)) - "owned buffers are deleted automatically at scope exit")) + "owned buffers are deleted automatically at scope exit") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (let [_sb (build "returned")] ()) + (Debug.memory-balance)) + "buffers returned from a function are deleted") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (ignore (consume (build "moved"))) + (Debug.memory-balance)) + "buffers moved into a function are deleted by the callee") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (let [sb (build "copied")] (let [_sb2 @&sb] ())) + (Debug.memory-balance)) + "copies are deleted independently") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (let [_a [(build "one") (build "two")]] ()) + (Debug.memory-balance)) + "buffers inside an array are deleted") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (let [_h (Holder.init (build "held"))] ()) + (Debug.memory-balance)) + "buffers inside a struct are deleted") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (ignore (StringBuf.into-string (build "consumed"))) + (Debug.memory-balance)) + "into-string hands the allocation over without leaking") + + (assert-equal test + 0l + (do + (Debug.reset-memory-balance!) + (StringBuf.delete (build "manual")) + (Debug.memory-balance)) + "explicit delete still works and does not double free"))