A Ruby UserPresenter < SimpleDelegator wraps a User: why is presenter.is_a?(User) false while presenter == user is true, and what else surprises you?
answer
- a BasicObject carrying a copied Kernel
- class and is_a? answer for the wrapper
- ==, eql?, hash, inspect forwarded
- User === presenter is false
- freeze reaches the wrapped object
basics
~20 sDelegator is a BasicObject subclass with a trimmed copy of Kernel, so class, is_a? and User === presenter describe the wrapper, while ==, eql?, hash, to_s and inspect are forwarded. Equality is therefore one-sided, and freeze freezes both objects.
solid answer
~40 s`Delegator` inherits from `BasicObject` and includes a copy of `Kernel` with `to_s`, `inspect`, `===`, `<=>`, `hash` and `!~` removed, so those calls, and anything else it does not define, go to the wrapped user. The Kernel methods it keeps answer for the wrapper itself: `presenter.class` is `UserPresenter`, `is_a?(User)` and `instance_of?(User)` are false, and `User === presenter`, which `case`/`when` and `in User` patterns use, is false because `Module#===` checks the real class. `Delegator#==` compares the wrapped object, so `presenter == user` is true, but `user == presenter` runs `User`'s own `==` and is false for a plain class. `inspect` prints the user, which misleads debugging. `freeze` freezes the wrapped user too, and `dup` and `clone` copy it. Code that type-checks should unwrap with `__getobj__` or rely on duck typing.
code
ruby · 21 linesrequire "delegate"
class User
attr_reader :email
def initialize(email)
@email = email
end
end
class UserPresenter < SimpleDelegator; end
user = User.new("[email protected]")
presenter = UserPresenter.new(user)
presenter == user # => true
user == presenter # => false
presenter.is_a?(User) # => false
User === presenter # => false
presenter.class # => UserPresenter
presenter.freeze
user.frozen? # => truego deeper
Remember that a SimpleDelegator wrapper is a different object of a different class, even though it answers the user's methods.
List which methods the wrapper answers itself and which it forwards, and explain why Module#=== ignores the forwarding.
Diagnose failed case/when branches, one-sided equality in include? and a frozen model caused by presenters, and move unwrapping to the right boundary.
Set the rule that presenters stay at the edges and domain code receives models, so identity and type checks never see wrappers.
## Where the wrapper's methods come from `Delegator`, the parent of `SimpleDelegator` and of every `DelegateClass` class, is built to be almost empty so that calls fall through to the wrapped object: 1. It inherits from **`BasicObject`**, not `Object`. 2. It includes a **copy of `Kernel`** from which `to_s`, `inspect`, `===`, `<=>`, `hash` and `!~` are removed, along with most private Kernel helpers. 3. It defines a handful of methods itself: `==`, `!=`, `eql?`, `!`, `freeze`, `methods`, `public_methods`, `protected_methods`, and marshalling hooks. 4. Everything else goes through `method_missing` to the target. So some questions are answered by the wrapper and some by the user, and the split is not obvious from outside. ## Answered by the wrapper versus forwarded | Call on the presenter | Who answers | Result for a wrapped User | |---|---|---| | `class` | wrapper (Kernel copy) | `UserPresenter` | | `is_a?(User)`, `kind_of?`, `instance_of?` | wrapper | false | | `nil?` | wrapper | false, even when wrapping nil | | `equal?(user)` | wrapper (BasicObject) | false | | `==`, `!=` | `Delegator`, comparing the target | `presenter == user` is true | | `eql?` | `Delegator`, asks `user.eql?(target)` | true | | `hash`, `to_s`, `inspect`, `<=>`, `===` | forwarded | the user's values | | `respond_to?(:email)` | wrapper, via `respond_to_missing?` | true | ## Equality is one-sided `presenter == user` is true because `Delegator#==` compares the wrapped object with the argument. The reverse, `user == presenter`, runs the **user's** `==`. For a plain class that is identity, and for a `Data` or `Struct` it checks the class too, so it is false. Collections expose the asymmetry: `[user].include?(presenter)` calls `user == presenter` for each element in turn and returns false, while `[presenter].include?(user)` is true. ## Type checks, case/when and pattern matching `User === presenter` is **`Module#===`**, a C-level check of the argument's real class. It does not call the wrapper's `is_a?`, and the wrapper's class is `UserPresenter`, so: - `case presenter when User` does not match; - `presenter in User` in pattern matching is false; - validations or serializers that branch on `is_a?(User)` treat the presenter as some other object. Duck typing (`respond_to?(:email)`) works, because `respond_to_missing?` answers for forwarded names. ## freeze, dup and clone - `presenter.freeze` calls `freeze` on the wrapped user **and** on the wrapper; freezing a presenter to make it read-only also freezes the model underneath. - `presenter.dup` and `presenter.clone` copy the wrapped object and wrap the copy, so changes to the copy do not reach the original user. ## Spotting a wrapper When a bug report says an object "is a User but fails the User check", confirm whether a wrapper is involved before changing anything: - `obj.is_a?(Delegator)` is true for any `SimpleDelegator` or `DelegateClass` wrapper, because `is_a?` is answered by the wrapper and `Delegator` is among its ancestors; - `obj.class` names the presenter class, while `obj.inspect` misleadingly shows the user; - `obj.__getobj__.class` shows what is inside; - `obj.equal?(user)` is false for a wrapper even when `obj == user` is true. ## Handling it in production 1. Wrap as late as possible, at the view or serialization edge, and pass the model, not the presenter, to domain code. 2. Unwrap explicitly with `__getobj__` where a library checks classes or identity. 3. Prefer `respond_to?` over `is_a?` in code that may receive either. 4. Override `inspect` on the presenter when logs must show that a wrapper is involved. 5. Do not freeze a presenter unless freezing the model is intended.
- What happens with presenter = SimpleDelegator.new(nil) in an if and with nil? and the ! operator?`if presenter` is truthy, because the wrapper object itself is neither nil nor false. `presenter.nil?` is false, since `Kernel#nil?` answers for the wrapper. `!presenter` is true, because `Delegator#!` negates the wrapped nil. The three disagree, so never wrap a value that may be nil.
- Why does presenter.inspect print the User rather than a UserPresenter?`inspect` and `to_s` are removed from Delegator's Kernel copy, so they reach `method_missing` and run on the wrapped user. Logs and error messages then hide the wrapper; override `inspect` in the presenter if that matters.
saying these in an interview costs you the question
- A SimpleDelegator wrapper reports the wrapped object's class
- presenter == user and user == presenter always agree
- case presenter when User matches because is_a? is forwarded
- Freezing a presenter leaves the wrapped model mutable
- presenter.inspect shows the UserPresenter wrapper