Rejected features
Proposals that were considered and explicitly turned down. Recorded here so the same ideas don’t come back as fresh suggestions next session.
Increment / decrement (++/--)
Considered: postfix $i++ and prefix ++$i.
Rejected because:
- The pre/post distinction is a real footgun - the two forms differ only
in expression context, which is exactly where bugs hide. Swift removed
++/--in version 3 (2016) for this exact reason. - The savings are tiny (three characters) and only apply to
+1/-1. - Python rejected them from the start and the language hasn’t suffered.
$i = $i + 1;is verbose but unambiguous; the readability cost is small.
Compound assignment (+=, -=, *=, /=, //=, %=)
Considered as an alternative to ++/--.
Rejected because:
- Several operators to add and remember for marginal ergonomic gain over
$x = $x + E;. - Slippery slope: would we also need a string-concat
+=? Anand=? Where does the family end? - Keeping a single assignment shape (
$x = EXPR;) makes source code uniform and matches Jennifer’s “one way to do each thing” stance.
Ternary operator (cond ? a : b)
Considered as a way to write expression-position conditional selection without bouncing through an intermediate variable:
# verbose - status quo
def grade as string;
if ($score >= 90) { $grade = "A"; } else { $grade = "B"; }
# what a ternary would let us write
def grade as string init $score >= 90 ? "A" : "B";
Python’s a if c else b and the C-family c ? a : b cover the
same need.
Rejected because:
- Parallel API to
if/else. Stance #1 is strict in Jennifer (++was rejected even though it’s syntactically distinct from$i = $i + 1). “Pick value A or B based on a condition” is one operation;if/elseis the canonical spelling. Adding a second syntax form would be the same kind of parallel API that++and+=were rejected for. - Closest stance neighbor agrees. Go is the only mainstream
language built on a “small, explicit” stance comparable to
Jennifer’s, and Go’s designers rejected ternary explicitly: “The
reason
?:is absent from Go is that the language’s designers had seen the operation used too often to create impenetrably complex expressions. The if-else form, although longer, is unquestionably clearer.” Real Go code lives without it; Jennifer programs will too. - Nesting is the footgun.
a ? b : c ? d : e ? f : gis parseable but unreadable. Once shipped, the ternary will end up in code like this and there’s no way to take it back. Rejecting upfront saves us the migration. - Verbose form has search/step affordances. A multi-line
if/elseis grep-friendly, debuggable, and editable line by line. The condensed expression form is none of these. For one saved line of source across the whole program, the cost is too high. - Escape hatch already exists. A user who really wants
ternary-shaped code can write a one-line helper
func pick(c as bool, a as int, b as int) { if ($c) { return $a; } return $b; }. Both arguments evaluate eagerly (no short-circuit), but for the cases where ternary is genuinely better this is fine.
The “make if itself an expression” alternative (Rust-style:
def x init if (c) { 1 } else { 2 };) was also considered and
deferred indefinitely. It’s the cleaner long-term answer if
expression-position conditionals ever become genuinely needed -
it extends an existing construct rather than introducing a new
operator - but it’s a much larger change (blocks have to evaluate
to their last expression; type-checking gets harder; if without
else and if containing return/exit need defined semantics).
We don’t owe ourselves that complexity for “save one line of
source” ergonomics.
Range literal syntax ([1..9])
Considered as a shorthand for constructing a list of int
sequence:
# what range literal would let us write
def xs as list of int init [1..9];
for (def i in [1..len($items)]) { ... }
Borrowed from Haskell / Kotlin / Ruby’s syntactic form.
Rejected because:
- Parallel API to the
[1, 2, 3]list literal. Two syntaxes for “construct alist of int” - same family as++,+=, and ternary. Stance #1 has rejected every previous instance. - Hides materialization cost (stance #2).
[1..big_number]silently allocates a million-element list. The explicitfor (def i init 1; $i <= n; ...)loop iterates without materializing, and the cost shows up at the call site instead of being buried in a two-character..operator. A library function call (lists.range(...)) makes the allocation visible too; the literal form does not. - New single-purpose operator. Jennifer hasn’t introduced an
operator that works in exactly one context before; every other
operator (
+ - * / // % & | ^ ~ << >> < > <= >= == and or not) works across multiple types or contexts...only does integer ranges. Once it ships we’d have to defend “why doesn’t[1.0..9.0]work? why not["a".."z"]?” - each extension another design discussion. - Pattern set elsewhere. Jennifer ships
lists.head/lists.tailinstead of Pythonxs[:n]/xs[n:]slicing syntax;strings.substringinstead ofs[i:j]; index-write$xs[i] = v;instead of pythonic$xs[i:i+1] = [v]. Library functions over syntax for collection operations is the established Jennifer pattern.
The chosen rule: ship lists.range(start, end) (M15.0) as the
canonical way to allocate an integer sequence. Use site reads as
“this allocates a list” instead of hiding behind two characters
of punctuation. See
milestones.md > M15.0.
printf data-transformation modifiers
Considered during the M7 format-verb-modifier design: extending the modifier list with options that transform the value rather than its visual representation. Examples from the original draft:
%s|case=upper|lower|title|snake|kebab|camel|pascal|leet%s|slice=START:END%s|md=italic|bold|code|strike|header1|header2|...|link(URL)|...%s|md=table(...)and the wider%a|json=*/%a|xml=*/%a|yaml=*family - serialisation modifiers for the aggregate verb.%aitself shipped in M11 with presentation-only modifiers (sep,kv,open,close,depth,null=skip); thejson=/xml=/yaml=family remains rejected as data transformation that belongs in dedicated libraries.null=sql(SQL-specific spelling ofNULL)
Rejected for the modifier system because:
- Mission creep. The guiding rule for printf modifiers is “shape
the printed representation, not the value.”
%d|base=2is presentation (the int 5 becomes the glyph sequence101).%s|case=upperis data transformation ("abc"becomes"ABC"- a different string value). Once one transform is in, every string/number/aggregate manipulation becomes a candidate modifier and the format-string spec swallows the rest of the standard library. - Parallel API to the libraries.
%s|case=upperis alreadystrings.upper($s). Two ways to do the same thing breaks Jennifer’s “one way per thing” stance and means every future string helper has to decide whether to ship as a function, a modifier, or both. - Domain leakage.
null=sqlpicks one application domain to bake into the formatter.null=literal("NULL")is already general - letting the user spell their own NULL keeps SQL, CSV, JSON, and any future format out of the printf spec. md=*is a separate library. Markdown rendering belongs in a futuremarkdownlibrary that returns strings, so the result composes withprintf,sprintf, string concat, file writing - anywhere a string goes. Folding it into%smodifiers would lock markdown output to print sites.
printf literal-pipe lookahead
Considered during M7 as a way to soften the breaking change to pre-M7
format strings: treat the | after a verb as a literal whenever the
next byte isn’t a lowercase letter, so "%s|%s" would keep working
because |% isn’t a key start.
Rejected because the rule is context-sensitive in exactly the wrong
direction. A user who writes "%s|fill text" (intending literal |)
would suddenly hit a parse error because fill is a valid-looking
key, while "%s|9 lives" would silently keep working because 9
isn’t a letter. The footgun moves around with whatever word follows
the verb.
The chosen rule is the strict one: | immediately after a verb
always starts a modifier list. To write a literal | in that
position, double it (||), parallel to the %% escape for a
literal %. The rule is uniform and easy to remember; the migration
cost was small (five test strings in this repo).
Verbatim print builtin (io.print / io.println)
Considered: a plain io.print(s) / io.println(s) that writes a
string with no format interpretation - the safe primitive for “just
emit this string,” since a dynamic value passed as io.printf(s)
misparses any % it contains (a generated password, user input, or
file bytes containing %c reads as an unknown verb, %s as a missing
argument).
Rejected because:
- It is a second way to do a job
printfalready covers. Any string prints withprintf("%s", s), soprint(s)is convenience sugar over that - exactly the “no twoprintfflavors for the same job” case stance 1 (one way per thing) rules out. - Keeping a single output entry point (
printf/sprintf/eprintf) keeps the surface uniform; a reader never wonders which printer a program uses. - The counter-argument - that a verbatim printer is a distinct primitive (no format language at all) rather than a printf flavor - is real but does not clear stance 1’s bar: the observable job (“put this string on stdout”) is the same, and the language prefers one canonical spelling even when a shorthand would be safer.
The accepted trade-off: printf("%s", s) is the mandatory idiom
for any dynamic or untrusted string, and printf(s) on such a value
is a latent bug. Documented in io.md so the
footgun is called out at the source rather than papered over with a
second builtin.
Methods on structs
Considered during M15.5 (time library) planning: let structs
declare methods that receive self implicitly, so accessors and
small operations on a struct value read as $t.year() instead of
time.year($t). The trigger was the time library’s many calendar
accessors; the same shape would later cover hash.Stream.update,
os.Process.kill, and every other library that holds onto state
behind a struct.
Rejected because:
- It’s the start of object orientation, not a syntactic shortcut.
Methods carry an implicit
selfbinding and re-open the question of polymorphism, dispatch (single? double?), inheritance vs composition, interfaces / traits, visibility modifiers, and constructors. Once any one of those is shipped the others become a “why not also” thread. Jennifer is procedural with value types; the small, explicit shape is the language’s identity, not a placeholder for an OO upgrade. - It invalidates every shipped library’s call shape. Today
lists.push($xs, x),maps.has($m, k),strings.upper($s),hash.update($s, $b),os.run($argv)are alllib.verb(receiver, args...). Adding$receiver.verb(args...)alongside would force each library to choose - or worse, ship both spellings and create the parallel-API problem stance #1 rejects. Picking method-form everywhere would mean rewriting every library and every example. - Stances #1 and #2 already cover the ergonomics complaint.
“One way per thing” - if methods exist the function form
becomes the second way. “Explicit over implicit” - the
function call shows the library name at the call site; the
method form hides it behind dispatch on
$receiver’s type. The cost oftime.year($t)vs$t.year()is one extra word; the cost of OO is a different language.
The chosen rule: structs have field access only. Operations on
struct values live as functions in the owning library
(time.year($t), hash.update($s, $b), os.kill($p)). If
ergonomics ever genuinely justify a receiver syntax, the
language change is large enough to merit its own milestone with
its own rejected.md trail of what the OO surface would not
include.
os.exit(n)
Considered during the M11 / M15.1 planning: ship process exit as
both the language statement exit EXPR; (M11) and as a library
function os.exit(n) (planned for M15.1). The argument for keeping
both was that the language statement might be redefined under a
future embedding (a WASM sandbox, a host application driving the
interpreter) to mean “return from the interpreter,” while
os.exit(n) would always mean “kill the host process” with no
possibility of redefinition. Same argument as C’s exit() vs
_exit().
Rejected because:
- They collapse to the same thing today. On a hosted OS the two forms have identical observable behaviour - same exit code, same stdout flush, same termination. Two spellings for one behaviour violates Jennifer’s “one way per thing” stance immediately, in exchange for a divergence that hasn’t been needed yet.
- The embedding case is hypothetical. A WASM-sandbox build and
a host-driven embedding are long-horizon items; designing the
public API for them now locks in a duplicate that the actual
embeddings may not even want (an embedded host might redefine
exit EXPR;andos.exit(n)the same way, leaving the distinction useless but still in the language). - The statement form is the right home. Process exit is
control-flow, parallel to
return;: terminating execution from any reachable point. Wrapping the same primitive in a library function would read as “call into the OS” when it’s actually “stop running.” The statement form keeps the intent visible.
The chosen rule: exit EXPR; (and bare exit;) is the only
spelling. If a future embedding does need a “always kill the host”
escape hatch, it ships then with a name that says what it does
(os.kill(), os.hardExit()), not as a near-duplicate of the
existing statement.
Implicit use NAME; fallback chain (M8+)
Considered during the M8-and-beyond roadmap discussion: have
use http; search the system libraries first, then any installed
WASM libraries, then a http.j on the file-import path, taking the
first one found. Same call-site spelling regardless of where the
implementation actually lives.
Rejected because:
- It violates “explicit over implicit.” Two programs with the
same source text would resolve
use http;to different implementations depending on what’s installed in the environment. At the call site (http.get(...)) the reader has no way to tell whichhttpis in scope. - Silent precedence shifts break things. Installing a WASM
httppackage would shadow a (slower / different-flavoured) systemhttp. The user’s program would change behaviour with no source edit and no visible diff. - Debuggability suffers. “Why does
http.getbehave this way?” becomes “whichhttpdid the resolver pick this time?” - a question that depends on the environment, not the code.
The chosen rule is explicit prefixes - the load source is visible
at the use site:
use net;→ system library only.use wasm:libname;→ WASM library (when that milestone lands).import "path/foo.j";→ file import (textual splice today; module-aware when M17 lands).
Users who genuinely want one entry point can write a tiny Jennifer-coded shim that picks an implementation explicitly; that’s a per-program decision, not a language-wide default.
FFI as a single milestone
Considered: a dedicated “FFI” milestone covering everything users typically mean by foreign function interface - calling C libraries, calling Go libraries, integrating with OS APIs, embedding Jennifer in host applications, reusing existing ecosystems.
Rejected because it’s three different problems wearing one name, and lumping them together obscures that two are already addressed and the third doesn’t fit:
- “Call Go libraries” is already the library mechanism. Every
<pkg>.Install(in)call registers Go functions as Jennifer builtins. That is FFI in everything but name. Anyone wanting to extend Jennifer with Go code writes a topic library today; no new surface needed. - “Integrate with OS APIs” is the topic-library job.
os,fs,net, and friends wrap Go’s stdlib (which wraps the OS). Adding more OS surface means filling out those libraries, not inventing an FFI keyword. - “Reuse existing ecosystems / call C libraries” lands through WASM (M19), not cgo. TinyGo’s cgo support is partial on hosted targets and absent on WASI / baremetal; small-footprint embedding targets typically lack a userspace libc to link against at all. The WASM runtime milestone already plans sandboxed module loading, which sidesteps the ABI / marshalling / ownership fight entirely.
- “Embed Jennifer in host applications” is a real gap - but it’s a Go-side polish job, not a language feature. It gets its own placeholder in the Long horizon list under “Host-embedding API”, separate from FFI as conventionally meant.
The chosen rule: no FFI milestone. Three named homes instead - topic libraries (Go + OS surface), WASM (M19, third-party ecosystems), and a future host-embedding API milestone (driving the interpreter from Go).
References, interior mutability, shared mutable state
Considered as escape hatches from the deep-copy cost that stance #5 (value semantics for collections) imposes on graph algorithms, large data structures, and zero-copy workflows. Three different shapes, considered together because they all relax the same invariant:
- Mutable references. A
&xsform that lets a callee write through to the caller’s binding (Go / C++ semantics). - Interior mutability. A wrapper type (Rust’s
Cell/RefCell) carrying a mutable cell behind an otherwise-immutable value, settable from anywhere holding the wrapper. - Shared mutable state. A first-class “shared list” or “shared map” kind whose writes are visible to every binding that points at the same underlying storage.
Rejected because:
- They break the local-reasoning guarantee that value
semantics is the whole point of. Today a reader of
func f(xs as list of int)knows the caller’s binding cannot be mutated byf- no aliasing, no surprises. Any of the three above forces every reader to ask “does this callee have a writable handle on my data?” at every call site. The cost is paid by every program, not just the ones that need the optimization. - Rust gets away with this only because the borrow checker pays the bill. Aliased mutation in Rust is sound because the type system rejects programs where two writable handles coexist. Jennifer has no such checker and the language’s whole point is local readability without a type-system tax; adding aliased mutation without the checker is just C semantics with prettier syntax.
- Pre-1.0 softening is a one-way door. Once any of these
ships, the “no aliasing” guarantee is gone for every future
reader. Even gating them behind a keyword (
shared,&mut) forces every other Jennifer programmer to learn the aliased-mutation rules to read other people’s code. - The performance gap is recoverable without semantic
change. Copy-on-write, arena allocation, and read-only
slice views (
xs[1..5]as a non-owning window that errors on assignment) close most of the gap that motivates the proposals. Those are interpreter-internal optimizations - they preserve “no aliasing” at the user level and are recorded as the “Performance & memory” placeholder in the Long horizon list. Graph algorithms specifically can use the M13.1 struct mechanism with explicit ID-keyed maps (map of int to Node) for parent / child links, which is the conventional fix in value-typed languages. - Shared state for concurrency belongs to M16.0. The concurrency milestone already plans channel / queue / spawn primitives that handle the cross-task communication case without exposing shared mutable values to single-threaded code.
The chosen rule: stance #5 stands without exception. The “Performance & memory” Long horizon entry covers the optimizations that close the cost gap; users who genuinely need aliased mutation are using the wrong language.
Auto-invoked module setup hook (M17)
Considered while designing the module system (M17). A module
could declare a magic func setup() (or init) that the
loader calls automatically the first time the module is
imported, giving a defined place for one-time initialization.
Rejected because:
- Redundant with
def const ... init EXPR;. Value-producing one-time work is already expressible:export def const TABLE as list of int init buildTable();wherebuildTableis a private modulefuncthat runs a loop and returns the result. The initializer runs exactly once at load (M17’s run-once semantics), so a separate hook buys nothing for the common case. - A pure-side-effect hook reintroduces the footgun M17 removed.
M17 modules are declarations-only with no mutable top-level
state, so
setup()could only do I/O or seed a shared source - exactly the “import does work” surprise the declarations-only rule is meant to eliminate (the lesson behind Python’s “imports should be cheap”). - Cuts against “no required entry point.” Jennifer deliberately has no auto-invoked entry point; an auto-called module hook is the same idea by another name.
- Stance #1 (one way).
def const ... init ...;already runs code once at load; a hook is a second spelling for “run at import.”
The chosen rule: a module that genuinely needs imperative setup
exports an ordinary function the consumer calls explicitly
(points.prepare();) - more explicit (stance #2) and it keeps
the work off the import path. No new language surface, no
auto-invocation.
Directory-as-module and cross-file re-export (M17)
Considered for multi-file modules: let a directory be a module,
imported by directory name (import "bigmod" as bigmod;)
resolving to a conventional entry file, and/or let several files
in the module each export with the module’s public surface
being their union (a cross-file re-export / package model, as in
Python packages, Go packages, or ES module re-exports).
Rejected because:
include-assembly already covers it with one mechanism. A multi-file module is a single entry.jfile thatincludes its parts (subdirectories allowed); the splice shares one module scope and oneexportsurface, and the consumer imports just the entry file. That is the M10 textual-splice mechanism plus M17.0’s declarations-only-and-export rule, with no new surface. See M17.1.- Stance #1 (one way). Directory-as-module would be a second, parallel way to compose a module on top of include-assembly, for the same outcome.
- Re-export reopens a closed question. Cross-file re-export is
the same “reach a name from elsewhere and republish it” shape as
the
from FOO import BARsymbol import M17.2 already turned down; adding it here would reintroduce that surface by another name. - Relaxing must-end-in-
.jfor directory imports removes the lexical marker that keeps import strings unambiguous, for an ergonomic gain include-assembly already delivers.
The chosen rule: multi-file modules assemble via include behind
one entry file (M17.1). Directory-as-module and cross-file
re-export can be revisited as their own milestone if real-world
module sizes ever make include-assembly unwieldy.
Mandatory public/private keyword on module declarations (M17.4)
Considered as a stronger form of M17.4’s export model: instead of
private-by-default with a single export marker, require an
explicit public or private keyword on every top-level def const, def struct, and func in a module (omitting it would
be a parse error), on the “explicit over implicit” (stance #2)
argument.
Rejected because:
- Stance #2 is already satisfied by
export. The property that must be explicit is the module’s public surface - “what does this expose?” - andexportanswers it by grep (grep '^export' mod.jis the whole public API). The only implicit fact left is “unmarked means private,” a one-line documented rule, not a hidden surprise. - Private-by-default is the fail-safe direction. A forgotten marker keeps a name internal; there is no path where omission leaks a name into the public surface, which is the failure mode the M17.3/M17.4 supply-chain-hygiene argument cares about.
- Every cited language uses marker-for-public, default-private.
Rust
pub, TypeScriptexport, Go capitalization, Java package-private +public- none requires a visibility keyword on every declaration. The ecosystem converged on exactly the chosen model. - Cheaper script-to-module promotion. Promoting a script means
adding
exportonly to the names being committed to a public API (M17.4’s deliberate “I now have a public API” moment). Mandatory keywords would force annotating every top-level name on promotion, a noisier diff for no safety gain. - Stance #1 (one way). Mandatory
public/privatedoubles the visibility vocabulary;export-or-nothing is one axis, one token. (This is also why the parallelpublicsynonym forexportand a standaloneprivatemarker are rejected - see M17.4.)
Implicit map-to-struct coercion at binding boundaries (M16.9 json typed decode)
Considered: letting a map of string to V value materialize a struct at
a typed binding implicitly - def p as Point init json.decode(s);
silently filling Point’s fields from the decoded object, erroring on
missing / unknown / wrong-type fields. It was the original M16.9 shape for
typed JSON decode. Only the implicit form is rejected here - an
explicit map-to-struct conversion (a spelled-out call or a struct-literal
spread, where the reader sees the conversion at the call site) is the
sanctioned path, deferred to Long horizon in milestones.md.
Rejected because:
- Jennifer does no coercion at binding boundaries, anywhere. The
boundary (
MatchesDeclared, at def-init, assignment, params, field writes) is strict: evendef x as float init 5;errors - you write5.0. A map-to-struct coercion would be the first exception, and a cross-cutting one (it fires wherever a struct type meets a map value), softening a stance the rest of the language holds uniformly. json.decodecan’t see the caller’s declared type (builtins don’t receive it), so the coercion could only live in the interpreter’s binding path - it is a language feature, not a library detail, and would apply to every map, not just decoded ones.- The explicit rebuild is short and self-documenting.
def p as Point init Point{ x: $m["x"], y: $m["y"] };names the schema at the call site, matching Jennifer’s write-it-out style; the encode direction (json.encode($p)) is unaffected.
So json.decode returns generic Values (objects to map of string to V); typed targets are rebuilt by hand, or - once specced - through the
explicit map-to-struct conversion (deferred; Long horizon). See M16.9 in
milestones.md and
libraries/json.md.
A language any / top type (M16.16 json.Value)
Considered: a general any type keyword - a top type every value matches -
as the home for heterogeneous data, its concrete driver being mixed-shape
JSON (def x as any;, list of any, map of string to any, with a
runtime-checked extraction back into concrete slots). Rejected in favour
of confining the dynamism to an opaque, destructure-only json.Value tree
(M16.16).
Rejected because:
- It is a language-wide type-system opt-out. An
anyvalue is usable at every expression site - operators dispatch on the runtime kind, sodef x as any init 5; def y as any init 3; $x + $y;just works. A beginner can declare everythinganyand sail past the type system entirely; the strict, teachable identity (“you always declare what you store”) is gutted to serve one library’s need. - The friction fixes are worse than the disease. Making
anyopaque (rejecting it at every operator so each use demands an explicit narrow) claws the strictness back, but only by bolting a whole second set of narrowing rules onto the type system - heavy machinery for what is really a per-source problem. - The dynamism is per-source, not universal. Heterogeneous data comes
from specific boundaries (a decoded JSON document; later, a DB row).
Each earns its own labelled, opaque, destructure-only type -
json.Value, a futuresql.Row- so the escape hatch stays visible and local, never a shared language feature.
So there is no any keyword; heterogeneous JSON lives in json.Value,
walked with explicit accessors. See M16.16 in
milestones.md.
printf i18n / string translation in format strings (M20.4 i18n)
The proposal: since stance 3 makes printf about presentation, and
translating "hello" to "hallo" presents the same meaning in another form,
extend (s)printf to look up translations - a format-string-level i18n.
Rejected because it fails the stance’s own test - “shape how a value is
rendered” (kept) vs “transform the value itself” (a library call, like
upper / markdown rendering):
- It is substitution, not rendering.
%d|base=2renders 255 as11111111- the same value, a pure function of value + modifier, no state. Translation replaces the string with a different one fetched from a catalog keyed by the current locale; the output has no mechanical relation to the input. That is transformation, sitting next toupperand markdown rendering, which the stance already makes library calls. - It smuggles in global state.
printfmodifiers are pure; translation needs an ambient locale + loaded catalogs, violating stance 2 (explicit) and stance 7 (namespaced, no globals), and it couplesprintftoi18n, killing the orthogonality stance 3 exists to protect. - Even gettext keeps it out of printf. The canonical
_()design translates the format string first, then formats the result:io.printf(i18n.tr("Hello, %s"), name). Translation and formatting already compose cleanly, so there is no gap for aprintfextension to fill.
So translation stays i18n.tr() (M20.4), composing with printf as above.
Locale-aware value formatting (a number with the locale’s grouping /
decimal, a date in the locale’s order) genuinely is presentation and is not
rejected - it is a separate, open printf question for later.
Write-through copy-on-write for compound Values (M19.2)
Value semantics rest on eager deep copies at every store site (def /
assignment / parameter binding / spawn snapshot), so no two live bindings ever
share a compound backing and the mutation sites can grow a binding’s own
backing in place - append-in-a-loop stays amortised O(N). An earlier attempt
added a copy-on-write layer on top: a Value.shared *bool marker set by
Share() on every VarExpr read and honoured by Ensure() at each mutation
site, meaning to defer the deep copy until an actually-aliased value is
mutated. It shipped inert - Share() has a value receiver and
Environment.Get / GetAt return the binding’s Value by value, so the flag
was set on a throwaway copy and never reached the stored binding; Ensure
never detached. Correctness never depended on it, and every compound read
heap-allocated a *bool that went nowhere. It was removed.
The write-through version that would make COW actually fire - store
*Binding (or otherwise let the marker propagate back to the stored value) so
a mutation site genuinely detaches an aliased backing - was considered and
rejected:
- The eager-copy invariant already makes aliasing rare. Because every store copies, the only values that are ever aliased are transient rvalues that get copied again at the next store. A binding almost never holds a value that another live binding also holds, so the deferred-copy path COW optimises is nearly never taken - it would add machinery for a case that eager copies have already designed away.
- It complicates the hot path it claims to help. Propagating the marker
means threading
*BindingthroughEnvironmentreads and every element / field slot, turning simple by-value reads into pointer bookkeeping and reintroducing shared mutable state the interpreter is otherwise free of. - The measured win is on append-in-a-loop, which already works. In-place growth of a binding’s own backing (safe precisely because nothing else aliases it) gives the O(N) append without any marker at all.
So Jennifer keeps eager copies plus one narrow optimisation - a fresh list /
map / struct literal RHS is already private, so the binding site skips the
redundant whole-value copy (rhsFreshLiteral). If profiling ever shows real
aliasing-heavy workloads paying for redundant copies, refcounted COW can be
revisited then.