Issue 1732: Match-Site Struct/Tuple Scrutinee Elision in Matchless
Status: proposed
Date: 2026-02-20
Issue: https://github.com/johnynek/bosatsu/issues/1732
Problem
Issue #1732 describes a common shape:
match (f, g):
case (..., ...):
Today this lowers to App(MakeStruct(2), ...) and later pattern matching reads with GetStructElement(...), so we allocate a tuple even when matching can read fields directly.
Decision Summary
- Yes: we can do this entirely in the
Matchlesslowering layer. - Yes: this generalizes to all struct-like single-constructor values lowered as
MakeStruct(arity), not just tuples. - Yes: we can support both matching backends (matrix and ordered sequential if/else) with one shared scrutinee representation.
Goals
- Elide match-site
MakeStructallocation when scrutinee is syntacticallyApp(MakeStruct(arity), args). - Preserve strict evaluation behavior (evaluate
argsonce, left-to-right, before branch selection). - Keep semantics identical across matrix and ordered backends.
- Reuse existing totality/typing invariants (no changes in
TypedExpr, parser, or typechecker).
Non-goals
- No source-language changes.
- No changes to totality checking.
- No lazy argument evaluation in phase 1.
Implementation Scope (files)
Implementation (follow-up PR) is concentrated in:
core/src/main/scala/dev/bosatsu/Matchless.scalacore/src/test/scala/dev/bosatsu/MatchlessTests.scala
This design-doc PR adds:
docs/src/main/paradox/design-docs/issue_1732_match_site_struct_scrutinee_elision_design.mddocs/src/main/paradox/design-docs/index.md
New Internal Abstractions and Functions
Add these inside Matchless.fromLet (near existing match helpers):
case class InlinedStructRoot(fields: Vector[CheapExpr[B]])- Represents a root scrutinee known to be a struct constructor application, already converted to cheap field expressions.
-
def prepareInlinedStructRoot(arg: Expr[B]): F[Option[InlinedStructRoot]] - Detects
argshapeApp(MakeStruct(arity), args)with matching arity. - Ensures strict single evaluation by introducing
LocalAnonlets for non-cheap args. - Returns cheap fields preserving source left-to-right order.
-
def structRootValue(root: InlinedStructRoot): Expr[B] - Reconstructs
App(MakeStruct(arity), fields)only when a branch binds the whole scrutinee (Var/Namedat root). -
def structField(root: InlinedStructRoot, idx: Int, size: Int): CheapExpr[B] - Returns
root.fields(idx)with arity check. -
def bindOccurrenceValue(occ: CheapExpr[B], inlined: Option[InlinedStructRoot]): Expr[B] - For normal occurrences, returns
occ. - For root inlined struct occurrence, returns
structRootValue(...). -
def projectStructOccurrence(occ: CheapExpr[B], idx: Int, size: Int, inlined: Option[InlinedStructRoot]): CheapExpr[B] - For normal occurrences, returns
GetStructElement(occ, idx, size). - For root inlined struct occurrence, returns
structField(...).
Ordered (sequential) matcher changes
Modify ordered compilation path (matchExprOrderedCheap + doesMatch) to thread optional root inlined-struct context:
- Extend
doesMatchwith an optionalrootInlined: Option[InlinedStructRoot]parameter used only at the top call. - In
Pattern.PositionalStruct+DataRepr.Struct/NewTypepath, replace directGetStructElement(arg, pos, size)withprojectStructOccurrence(...). - In root
Pattern.Var/Pattern.Namedbinding cases, bind viabindOccurrenceValue(...)so full-value binds still work. - Recursive
doesMatchcalls on subpatterns passNone(only the root may be inlined).
Result: sequential fallback gets the same no-allocation benefit as matrix compilation.
Matrix matcher changes
Modify matrix compilation (matchExprMatrixCheap) so root-struct projections also go through shared projection helpers:
- At entry, call
prepareInlinedStructRoot(arg). - If
None, keep current behavior. - If
Some(root), compile with a root context flag and use: projectStructOccurrence(...)inbuildCaseforStructSig.bindOccurrenceValue(...)fromnormalizeRow/peelPatternwhen emitting alias binds for root occurrence.- Keep existing projection materialization (
materializeOccs) for non-root projected occurrences.
This keeps matrix sharing logic unchanged while replacing root GetStructElement reads with direct root field values.
Why this is Matchless-only
This optimization depends on lowered constructor forms (MakeStruct + projection nodes) and backend matcher internals (doesMatch, buildCase, peelPattern).
No changes are needed in:
TypedExprshape/typechecking.- Totality checker.
- Source converter/parser.
Generalization Beyond Tuple
Tuples and user-defined single-constructor struct-like values already lower to MakeStruct(arity) in Matchless.
So detection on App(MakeStruct(arity), args) automatically covers:
- tuple literals,
- newtypes lowered as arity-1 struct,
- any struct constructor lowered to
MakeStruct.
No tuple-specific code path is required.
Strictness and the “lazy fields” idea
Issue #1732 mentions possible lazy field evaluation via LocalAnonMut/SetMut.
Phase 1 intentionally stays strict:
- Evaluate all scrutinee args exactly once before matching.
- Elide only the container allocation (
MakeStructresult object).
Because Bosatsu is total and pure, reordering this call graph is semantics-preserving. The phase-2 question is therefore performance-only.
Optional phase 2 (separate issue/PR): lazy per-field memo cells, only when profiling suggests a likely win (or when a static condition can prove it is beneficial).
Whole-scrutinee bind policy
If a branch binds the entire scrutinee (case x / case ... as x), we need a value for that bind.
The design supports branch-local reconstruction (structRootValue) so allocation happens only on branch paths that actually bind the root.
For phase 1, use this policy:
- No branch binds the whole scrutinee: keep allocation elided.
- Exactly one branch binds the whole scrutinee: reconstruct only in that branch.
- More than one branch binds the whole scrutinee: disable this optimization for that match and keep eager root allocation.
Rule (3) is a performance guardrail, not a semantic requirement, and can be relaxed later with benchmark data.
Test Plan
Add tests in core/src/test/scala/dev/bosatsu/MatchlessTests.scala:
matrix match elides tuple allocation at match site:- compile
match (f(), g()): ...and assert no rootApp(MakeStruct(2), ...)allocation is required for projection-only branches. -
ordered matcher also elides tuple allocation for non-orthogonal suffix: - force ordered fallback (e.g. list/string search suffix) and assert the same root elision.
-
single-constructor struct behaves like tuple: - hand-written typed struct constructor scrutinee
App(MakeStruct(n), ...)gets identical projection elision. -
whole-root binding still works: - include
case x as .../case xbranch and verify root value reconstruction is only on branch path that binds it. -
strict evaluation preserved: - arguments are still evaluated once and in order (no duplication, no skipping).
Risks and Mitigations
- Risk: regressions in root alias binding (
Pattern.Named/Pattern.Var). - Mitigation: explicit whole-root binding tests in both matrix and ordered paths, plus a compile-time count of whole-root bind branches to apply the phase-1 policy above.
-
Risk: accidental fallback divergence between matrix and ordered backends.
- Mitigation: mirror tests that force each backend.
-
Risk: introducing duplicate evaluations of non-cheap fields.
- Mitigation:
prepareInlinedStructRootalways memoizes non-cheap args before matching.
Rollout
- Land this design doc.
- Implement in
Matchless.scalabehind existing matcher-selection flow. - Add regression tests and compare generated Matchless trees before/after for representative tuple/struct matches.