From d469659da07b1acb958e5216afefd730db46742b Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Wed, 17 Jun 2026 15:53:31 -0500 Subject: [PATCH] Support freeze: pre-populate @normalized before locking --- AGENTS.md | 14 ++++++++++++-- lib/unit/class.rb | 19 +++++++++++++++++++ spec/unit_spec.rb | 14 ++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9e16a4..6819e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,18 @@ conversions, and expression parsing (`Unit('1 m/s^2')`), plus an optional DSL - **`Unit` is a `Numeric` subclass, and modern Ruby treats numerics as immutable value objects** — `dup`/`clone` return `self` by default. `Unit` overrides - `#dup` precisely so copy-based methods like `#normalize` don't mutate the - receiver. Don't reintroduce reliance on the default `dup`. + `#dup` so copy-based methods like `#normalize` don't mutate the receiver; + `#freeze` is overridden for the complementary reason — it pre-populates + `@normalized` while the object is still mutable, because the lazy + `@normalized ||=` in `normalize` would raise `FrozenError` on the first + comparison otherwise. Don't reintroduce reliance on the default `dup`, and + don't add new lazy ivars that bypass the `freeze` pre-population. +- **Comparison semantics:** `==` returns `false` for objects that are neither + `Numeric` nor coerceable (never raises). `<=>` raises `ArgumentError` for + compatible-type but incompatible-dimension operands (e.g. metres vs seconds); + it returns `nil` for non-`Numeric`, non-coerceable objects, honouring Ruby's + `<=>` contract. Keep this split: incompatible *types* → `nil`; incompatible + *dimensions* → `ArgumentError`. - **Not every unit is loaded by `require 'unit'`.** Only SI + binary + degree + time load by default; units such as `MeV` (scientific) need their system loaded first, e.g. `Unit.default_system.load(:scientific)`. diff --git a/lib/unit/class.rb b/lib/unit/class.rb index 2266c23..5635d52 100644 --- a/lib/unit/class.rb +++ b/lib/unit/class.rb @@ -27,6 +27,17 @@ def initialize_copy(other) @normalized = other.normalized end + # Pre-populate +@normalized+ before locking the object. + # + # +Unit+ is a +Numeric+ subclass, so Ruby treats it as an immutable value + # object. The lazy +@normalized ||=+ in +normalize+ would raise +FrozenError+ + # on the first comparison call if the instance were frozen first; calling + # +normalize+ here ensures the ivar is already set when +super+ seals it. + def freeze + normalize + super + end + # Converts to base units def normalize @normalized ||= dup.normalize! @@ -113,6 +124,9 @@ def zero? value.zero? end + # Returns +false+ for any object that is neither +Numeric+ nor coerceable, + # rather than raising. This keeps mixed-type equality checks safe (e.g. + # comparing against +nil+, strings, or arbitrary objects). def ==(other) if Numeric === other other = coerce_numeric(other) @@ -129,6 +143,11 @@ def eql?(other) Unit === other && value.eql?(other.value) && unit == other.unit end + # Raises +ArgumentError+ when both operands are +Numeric+ but have + # incompatible dimensions (e.g. metres vs seconds); returns +nil+ for + # non-+Numeric+, non-coerceable objects. This split honours Ruby's +<=>+ + # contract (return +nil+ for incomparable types) while still surfacing + # dimension mismatches loudly through every comparison operator. def <=>(other) if Numeric === other coerced = coerce_numeric(other) diff --git a/spec/unit_spec.rb b/spec/unit_spec.rb index 49d6a5d..f5fd91c 100644 --- a/spec/unit_spec.rb +++ b/spec/unit_spec.rb @@ -213,6 +213,20 @@ end end + describe "#freeze" do + it "allows >= comparison without raising FrozenError" do + expect(Unit(1, "m").freeze >= Unit("0.9 m")).to be true + end + + it "allows < comparison without raising FrozenError" do + expect(Unit(60, "Hz").freeze < Unit("61 Hz")).to be true + end + + it "allows == comparison without raising FrozenError" do + expect(Unit(1, "kg").freeze == Unit("1 kg")).to be true + end + end + it 'should convert units' do expect(Unit(1, "MeV").in("joule")).to eq(Unit(1.602176487e-13, 'joule')) expect(Unit(1, "kilometer").in("meter")).to eq(Unit(1000, 'meter'))