[
https://issues.apache.org/jira/browse/GROOVY-12135?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Paul King resolved GROOVY-12135.
--------------------------------
Fix Version/s: 6.0.0-beta-1
Resolution: Fixed
> groovy-contracts: Add a ThrowsIf annotation
> -------------------------------------------
>
> Key: GROOVY-12135
> URL: https://issues.apache.org/jira/browse/GROOVY-12135
> Project: Groovy
> Issue Type: Improvement
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
> Fix For: 6.0.0-beta-1
>
>
> h2. Summary
> groovy-contracts covers Eiffel's *normal-behaviour* contracts —
> {{@Requires}}, {{@Ensures}}, {{@Invariant}} — and nothing exceptional. Yet
> one of the most common contract-shaped code Groovy programmers write by hand
> is the *guard clause*: test an argument, throw {{IllegalArgumentException}} /
> {{NullPointerException}} / {{ArithmeticException}}. This proposes one
> annotation to make that behaviour a first-class, machine-readable, optionally
> *woven* contract:
> {code:groovy}
> @ThrowsIf(value = { b == 0 }, exception = ArithmeticException)
> int divide(int a, int b) { a.intdiv(b) }
> {code}
> read as an *iff*: the method throws {{ArithmeticException}} _exactly when_
> {{b == 0}} — it must throw when the condition holds, and may not throw that
> exception otherwise. With the default {{woven = true}}, groovy-contracts
> *generates* the guard at method entry — the general form of a pattern Groovy
> core already ships: {{groovy.transform.NullCheck}} weaves exactly this kind
> of guard for the null-check special case (as does Lombok's {{@NonNull}} for
> Java). The annotation is the implementation, not a comment about one.
> h2. Motivation
> # *The missing quadrant.* Design-by-Contract in the Eiffel lineage specifies
> the happy path; exceptional behaviour is left to javadoc prose. JML closed
> this gap for Java with {{signals}} clauses; groovy-contracts has no analogue.
> Guard clauses are today boilerplate ({{if (b == 0) throw ...}},
> {{Objects.requireNonNull\(y)}}, Guava {{Preconditions}}) — behaviour every
> caller depends on, in a form no tool can consume.
> # *A {{@Requires}} is the wrong tool for it.* A precondition says _the caller
> must not do this_ — violating it is the caller's bug. A guard throw says
> _this input is handled, by throwing_ — it is *defined behaviour* callers may
> rely on (and catch). The JDK's own culture is overwhelmingly defined-throws,
> not preconditions: {{Math.floorDiv}} on a zero divisor _throws_, by
> specification.
> # *Machine-readable exceptional behaviour — the AI-agent case.* To answer
> "when does this method throw?" a tool or AI coding agent must today parse
> javadoc prose, traverse the body for guarded throw sites (transitively), or
> read checked-exception signatures (types only, unchecked exceptions
> invisible). None is reliable; javadoc rots silently. An {{@ThrowsIf}} arm is
> one structured line — condition, type, direction — and because it is
> enforced, it cannot drift from the code without failing a build.
> # *It composes with verification but does not require it.* The design
> originates in groovy-verify (an SMT-backed verifier on the {{@TypeChecked}}
> extension SPI), which statically proves both directions of the iff — but
> every feature proposed here has standalone runtime value.
> h2. Proposed annotation
> {code:groovy}
> @Repeatable(ThrowsIfConditions)
> @Retention(RetentionPolicy.RUNTIME)
> @Target([ElementType.METHOD, ElementType.CONSTRUCTOR])
> @interface ThrowsIf {
> Class value() // the condition, a closure over the
> parameters: { b == 0 }
> Class exception() default Throwable // the exception type thrown when
> the condition holds
> boolean woven() default true // true: generate the guard-throw at
> method entry;
> // false: the throw already exists —
> nothing is generated
> boolean direct() default true // information for tools, no effect
> on bytecode; ignored
> // (implicitly true) for woven arms.
> true: a hand-written
> // throw statement lives in this
> body; false: the exception
> // arises from code this method
> executes (a call, possibly
> // transitive — e.g. a third-party
> library — or a runtime
> // operation), so there is no throw
> statement here to find
> boolean exhaustive() default true // no bytecode effect on its own;
> under checked = true it
> // gates the escaping-throw check —
> false says "this method
> // may throw the same exception for
> other, unlisted reasons",
> // so escaping throws are never
> judged
> boolean checked() default false // true: verify the contract at
> runtime in the assertion
> // style of @Ensures — see Checking
> below
> }
> {code}
> with {{ThrowsIfConditions}} the standard repeatable container. Conditions
> accept the same closure conventions as {{@Requires}} (bare free variables
> resolve to parameters).
> The first question a Groovy developer asks of an annotation is _what changes
> in the generated code?_ — so, member by member:
> || member || effect on generated code ||
> | {{woven}} | {{true}} (default): the guard — {{if (cond) throw new
> E('@ThrowsIf: cond')}} — is inserted at method entry. {{false}}: nothing is
> generated. |
> | {{checked}} | {{true}}: the verification wrapper is generated — condition
> snapshots at entry, the body wrapped in try/catch/finally (see Checking).
> {{false}} (default): nothing. |
> | {{exception}} | the type the woven guard constructs, and the type the
> checked wrapper matches escaping throws against. |
> | {{exhaustive}} | nothing on its own. Under {{checked = true}} it gates the
> catch-side check: {{true}} (default) means an escaping matching exception
> with no condition held is a violation; {{false}} means escaping throws are
> never judged — the method may throw the same exception for unlisted reasons.
> One {{false}} arm disclaims the check for the whole arm-set. |
> | {{direct}} | nothing, ever — pure information for readers and tools (see
> below). |
> So exactly two members generate code ({{woven}}, {{checked}}); {{exhaustive}}
> modulates what {{checked}} polices; {{direct}} and the unwoven/unchecked
> cases are structured metadata.
> Two points about {{direct}} worth making explicit. First, it inverts the
> usual annotation-attribute mental model: it has *no effect on bytecode* —
> {{woven = false}} arms generate nothing either way — it is pure *information*
> for readers and tools about where the existing throw is implemented, and
> therefore where to look and who can verify it ({{direct = false}} tells an
> analysis tool or AI agent not to traverse this body looking for a throw
> statement, and is also why such arms are never woven: generating a guard from
> a wrong claim about invoked code would silently change behaviour). Second, it
> is designed for progressive disclosure: the defaults mean most users never
> think about it — a developer writes {{woven = false}} for a hand-written
> guard and {{direct = true}} is already right; the first contact with {{direct
> = false}} is typically a verification tool reporting it cannot find the
> promised throw in the body, at which moment the flag is meaningful. The
> reference implementation's transform never reads {{direct}} at all —
> "information only" is true by construction.
> *Semantics — the claims tools consume.* In specification terms an arm states
> two directions: *must-throw* (condition on entry ⇒ the method throws, not
> returns — implemented by the woven guard, or policed by the checked wrapper's
> normal-return check) and *only-when* (a matching throw ⇒ some arm's condition
> held — policed by the checked wrapper's catch-side check, and only when the
> arm-set is {{exhaustive}}). The default is therefore an *iff*; {{exhaustive =
> false}} is the one-directional (JML {{signals}}-style) form — the honest
> choice when the full condition is unstatable: {{Integer.parseInt}}'s real
> throw condition (malformed _or_ out of range) is beyond a parameter closure,
> but {{s == null}} is a true sufficient half. Two deliberate non-claims: no
> {{signals_only}} (an arm-set is exhaustive over the _conditions for the types
> it mentions_, never over exception _types_ — an {{ArithmeticException}} arm
> says nothing about whether the method can throw anything else), and multiple
> arms are independent must-throws while only-when is a property of the whole
> set.
> h2. The three modes, by example
> *Woven* (the default) — the Summary's {{divide}}: the annotation _is_ the
> guard, generated at method entry.
> *Unwoven, direct* ({{woven = false}}) — the body already implements the
> throw; the annotation is checkable documentation:
> {code:groovy}
> @ThrowsIf(value = { n < 0 }, exception = IllegalArgumentException, woven =
> false)
> int fact(int n) {
> if (n < 0) throw new IllegalArgumentException('negative')
> ...
> }
> {code}
> *Unwoven, indirect* ({{woven = false, direct = false}}) — the throw
> originates in code this method executes; the author documents behaviour they
> do not implement, and there is no throw statement in this body to find:
> {code:groovy}
> @ThrowsIf(value = { y == null }, exception = NullPointerException, woven =
> false, direct = false)
> Object myMethod(Object x, Object y) {
> ...
> Objects.requireNonNull(y) // the library throws; the annotation records
> the contract
> ...
> }
> {code}
> The attributes are per-arm because real methods mix modes:
> {code:groovy}
> @ThrowsIf(value = { x == null }, exception = NullPointerException)
> // woven for me
> @ThrowsIf(value = { y == null }, exception = NullPointerException, woven =
> false) // I already guard y
> Object process(Object x, Object y) { ... }
> {code}
> h2. Checking — the runtime iff
> Weaving *implements* the contract; {{checked = true}} additionally *verifies*
> it at runtime, in the assertion style of {{@Ensures}}: on a normal return, no
> non-woven arm's condition may have held on entry (must-throw), and an
> escaping exception matching some arm's type must be justified by a matching
> arm's condition having held (only-when — checked only for {{exhaustive}}
> arm-sets). Conceptually:
> {code:groovy}
> boolean $c0 = <cond0>; ... // entry snapshot,
> every arm
> if ($c0) throw new E0(...) // woven arms, in
> declaration order
> Throwable $t = null
> try { <original body> }
> catch (Throwable caught) {
> $t = caught
> throw ThrowsIfSupport.onlyWhen(caught, [$c0, ...], [E0, ...], [texts],
> allExhaustive)
> } finally {
> if ($t == null) ThrowsIfSupport.mustThrow([non-woven $ci ...], [texts])
> // normal return only
> }
> {code}
> A broken implementation raises {{ThrowsIfViolation}} (an
> {{AssertionViolation}} sibling of {{PostconditionViolation}}) — deliberately
> *never* the declared exception, which is defined behaviour delivered at
> entry; a justified throw always propagates to the caller untouched. Without
> {{checked}}, the runtime enforces at most the must-throw half (by
> construction, for woven arms) — the checked mode is what makes the iff real
> at runtime, symmetric with {{@Requires}}/{{@Ensures}} being checked contracts
> rather than documentation. A pleasant corollary: {{woven = false, direct =
> false}} plus {{checked = true}} runtime-validates a claim about invoked
> third-party code — a wrong specification is exposed, not silently believed.
> If any arm is {{checked}}, the whole arm-set participates in the only-when
> justification.
> h2. What users get with no verifier anywhere
> * *Declarative guard clauses* — the boilerplate every service method opens
> with becomes one line, with a consistent auto-derived message. Groovy core
> has already accepted this pattern once: {{groovy.transform.NullCheck}} is
> precisely a woven guard-throw, specialised to null checks — {{@ThrowsIf}}
> generalises it to arbitrary conditions and exception types (Lombok's
> {{@NonNull}} is the same precedent on the Java side).
> * *A runtime-checked exceptional contract* — {{checked = true}} verifies both
> directions of the iff in the same assertion style as {{@Ensures}}, including
> validating {{direct = false}} claims about third-party callees.
> * *Exceptional behaviour as structured metadata* — RUNTIME-retained,
> repeatable, reflectively consumable by doc generators, test generators,
> static analysers, and AI coding agents. The {{direct}} member carries
> information no other source provides reliably: whether a throw statement
> exists in the body at all ({{direct = false}} tells an agent not to traverse
> looking for one).
> * *A vocabulary distinction the ecosystem lacks* — {{@Requires}} = the
> caller's obligation; {{@ThrowsIf}} = the method's defined exceptional
> behaviour. Callers _may_ rely on a defined throw; they may _not_ rely on
> precondition-violation behaviour.
> h2. Evidence
> The semantics are the stable survivor of several design rounds field-tested
> in groovy-verify (observational vs generative weaving; a whole-method
> {{@Trusted}} marker, rejected by the mixed-method case; iff-only, rejected by
> {{parseInt}}; a {{trusted}} boolean whose name read as behavioural while its
> meaning was informational, and a three-valued {{Origin}} enum that fixed that
> at the cost of upfront cognitive load — both replaced by {{woven}}/{{direct}}
> with progressive-disclosure defaults). A pinned corpus exercises every
> direction — iff verification and both refutation directions, mixed-mode
> methods, indirect arms including vacuity detection, {{exhaustive = false}}
> tolerating unlisted throw reasons while must-throw stays enforced — and a
> differential runtime rung cross-validates proved contracts against real
> execution over an input grid. A reference implementation targeting
> groovy-contracts directly accompanies this issue: the annotation pair, a
> SEMANTIC_ANALYSIS local transform in the {{@Decreases}} style (guard weaving
> in declaration order + the checked wrapper; {{direct}} deliberately unread),
> {{ThrowsIfSupport}} / {{ThrowsIfViolation}}, and 18 tests including the
> {{@Requires}} interaction cases and {{direct}}-ignored-for-woven-arms.
> h2. Compatibility
> Purely additive: a new annotation pair, no change to existing annotations or
> weaving. The weaving-order question is settled empirically and pinned by
> tests: the {{@Requires}} assertion weaves ahead of the {{@ThrowsIf}}
> prologue, so an input violating both reports as {{PreconditionViolation}} (a
> caller bug is judged first), and a precondition whose *evaluation* throws
> surfaces raw — pre-body throws never reach the checked wrapper, so they
> cannot be misjudged as contract violations ({{AssertionViolation}}s are
> additionally passed straight through the checker as belt-and-braces). The one
> deliberately tolerated redundancy: {{direct}} is ignored (implicitly true)
> for woven arms, pinned by a test, rather than being an error — most users
> should never have to think about it.
> h2. Open questions
> # *Inheritance* — does an override inherit arms, and what is the Liskov rule
> for exceptional contracts? Deliberately undesigned; gc's existing
> {{@Requires}}/{{@Ensures}} inheritance semantics should drive it.
> # *Default for {{checked}}* — {{false}} (opt-in overhead) as implemented, or
> {{true}} for symmetry with {{@Requires}}/{{@Ensures}}, which check by default?
> # *Naming* — {{@ThrowsIf}} reads one-directional while the default is an iff.
> {{@Signals}} would be actively misleading (JML's {{signals}} is the converse
> direction); {{@ThrowsIff}} is accurate but unpronounceable. {{@ThrowsIf}} +
> documented iff default is the least-bad. For the mode attributes, {{trusted}}
> (behavioural-sounding), a three-valued {{Origin}} enum ({{WOVEN | BODY |
> INVOKED}} — self-describing but upfront cognitive load), and
> {{DIRECT/INDIRECT}} / {{EXPLICIT/IMPLICIT}} constant pairs were all
> considered before landing on the {{woven}}/{{direct}} booleans with
> progressive-disclosure defaults; better suggestions welcome.
> # *A possible follow-up, not part of this issue* — the same
> {{woven}}/{{direct}} pair reads naturally on {{@Requires}} for preconditions
> already enforced in-body ({{@Requires(value = \{ y != null }, woven = false,
> direct = false)}} over an {{Objects.requireNonNull}}): the caller-obligation
> claim, documented without double-weaving or changing the observed exception
> type. That touches the classic precondition processor and its inheritance
> machinery, so it deserves its own issue once this one settles.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)