skip to content

A Ruby UserPresenter < SimpleDelegator wraps a User: why is presenter.is_a?(User) false while presenter == user is true, and what else surprises you?

level: seniorimportance: should knowfreq 28%

answer

  1. a BasicObject carrying a copied Kernel
  2. class and is_a? answer for the wrapper
  3. ==, eql?, hash, inspect forwarded
  4. User === presenter is false
  5. freeze reaches the wrapped object

basics

~20 s

Delegator 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 lines
ruby
require "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?               # => true

go deeper

for a junior

Remember that a SimpleDelegator wrapper is a different object of a different class, even though it answers the user's methods.

for a middle

List which methods the wrapper answers itself and which it forwards, and explain why Module#=== ignores the forwarding.

for a senior

Diagnose failed case/when branches, one-sided equality in include? and a frozen model caused by presenters, and move unwrapping to the right boundary.

for a principal

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