Overriding the << method for instance variables

无人久伴 提交于 2021-02-11 07:37:36

问题


Let's suppose I have this class:

class Example
  attr_accessor :numbers

  def initialize(numbers = [])
    @numbers = numbers
  end

  private

  def validate!(number)
    number >= 0 || raise(ArgumentError)
  end
end

I would like to run the #validate! on any new number before pushing it into the numbers:

example = Example.new([1, 2, 3])
example.numbers # [1, 2, 3]
example.numbers << 4
example.numbers # [1, 2, 3, 4]
example.numbers << -1 # raise ArgumentError

Below is the best I can do but I'm really not sure about it.

Plus it works only on <<, not on push. I could add it but there is risk of infinite loop...).

Is there a more "regular" way to do it? I couldn't find any official process for that.

class Example
  attr_accessor :numbers

  def initialize(numbers = [])
    @numbers = numbers
    bind = self # so the instance is usable inside the singleton block
    @numbers.singleton_class.send(:define_method, :<<) do |value|
      # here, self refers to the @numbers array, so use bind to refer to the instance
      bind.send(:validate!, value)
      push(value)
    end
  end

  private

  def validate!(number)
    number >= 0 || raise(ArgumentError)
  end
end

回答1:


Programming is a lot like real life: it is not a good idea to just run around and let strangers touch your private parts.

You are solving the wrong problem. You are trying to regulate what strangers can do when they play with your private parts, but instead you simply shouldn't let them touch your privates in the first place.

class Example
  def initialize(numbers = [])
    @numbers = numbers.clone
  end

  def numbers
    @numbers.clone.freeze
  end

  def <<(number)
    validate(number)
    @numbers << number
    self
  end

  private

  def validate(number)
    raise ArgumentError, "number must be non-negative, but is #{number}" unless number >= 0
  end
end

example = Example.new([1, 2, 3])
example.numbers # [1, 2, 3]
example << 4
example.numbers # [1, 2, 3, 4]
example << -1 # raise ArgumentError

Let's look at all the changes I made one-by-one.

cloneing the initializer argument

You are taking a mutable object (an array) from an untrusted source (the caller). You should make sure that the caller cannot do anything "sneaky". In your first code, I can do this:

ary = [1, 2, 3]
example = Example.new(ary)

ary << -1

Since you simply took my array I handed you, I can still do to the array anything I want!

And even in the hardened version, I can do this:

ary = [1, 2, 3]
example = Example.new(ary)

class << ary
  remove_method :<<
end

ary << -1

Or, I can freeze the array before I hand it to you, which makes it impossible to add a singleton method to it.

Even without the safety aspects, you should still do this, because you violate another real-life rule: Don't play with other people's toys! I am handing you my array, and then you mutate it. In the real world, that would be considered rude. In programming, it is surprising, and surprises breed bugs.

cloneing in the getter

This goes to the heart of the matter: the @numbers array is my private internal state. I should never hand that to strangers. If you don't hand the @numbers array out, then none of the problems you are protecting against can even occur.

You are trying to protect against strangers mutating your internal state, and the solution to that is simple: don't give strangers your internal state!

The freeze is technically not necessary, but I like it to make clear to the caller that this is just a view into the state of the example object, and they are only allowed to view what I want them to.

And again, even without the safety aspects, this would still be a bad idea: by exposing your internal implementation to clients, you can no longer change the internal implementation without breaking clients. If you change the array to a linked list, your clients are going to break, because they are used to getting an array that you can randomly index, but you can't randomly index a linked list, you always have to traverse it from the front.

The example is unfortunately too small and simple to judge that, but I would even question why you are handing out arrays in the first place. What do the clients want to do with those numbers? Maybe it is enough for them to just iterate over them, in which case you don't need to give them a whole array, just an iterator:

class Example
  def each(...)
    return enum_for(__callee__) unless block_given?
    @numbers.each(...)
    self
  end
end

If the caller wants an array, they can still easily get one by calling to_a on the Enumerator.

Note that I return self. This has two reasons:

  1. It is simply the contract of each. Every other object in Ruby that implements each returns self. If this were Java, this would be part of the Iterable interface.

  2. I would actually accidentally leak the internal state that I work so hard to protect! As I just wrote: every implementation of each returns self, so what does @numbers.each return? It returns @numbers, which means my whole Example#each method returns @numbers which is exactly the thing I am trying to hide!

Implement << myself

Instead of handing out my internal state and have the caller append to it, I control what happens with my internal state. I implement my own version of << in which I can check for whatever I want and make sure no invariants of my object are violated.

Note that I return self. This has two reasons:

  1. It is simply the contract of <<. Every other object in Ruby that implements << returns self. If this were Java, this would be part of the Appendable interface.

  2. I would actually accidentally leak the internal state that I work so hard to protect! As I just wrote: every implementation of << returns self, so what does @numbers << number return? It returns @numbers, which means my whole Example#<< method returns @numbers which is exactly the thing I am trying to hide!

Drop the bang

In Ruby, method names that end with a bang mean "This method is more surprising than its non-bang counterpart". In your case, there is no non-bang counterpart, so the method shouldn't have a bang.

Don't abuse boolean operators for control flow

… or at least if you do, use the keyword versions (and / or) instead of the symbolic ones (&& / ||).

But really, you should void it altogether. do or die is idiomatic in Perl, but not in Ruby.

Technically, I have changed the return value of your method: it used to return true for a valid value, now it returns nil. But you ignore its return value anyway, so it doesn't matter.

validate is probably not a good name for the method, though. I would expect a method named validate to return a boolean result, not raise an exception.

An exceptional message

You should add messages to your exceptions that tell the programmer what went wrong. Another possibility is to create more specific exceptions, e.g.

class NegativeNumberError < ArgumentError; end

But that would be overkill in this case. In general, if you expect code to "read" your exception, create a new class, if you expect humans to read your exception, then a message is enough.

Encapsulation, Data Abstraction, Information Hiding

Those are three subtly different but related concepts, and they are among the most important concepts in programming. We always want hide our internal state and encapsulate it behind methods that we control.

Encapsulation to the max

Some people (including myself) don't particularly like even the object itself playing with its internal state. Personally, I even encapsulate private instance variables that are never exposed behind getters and setters. The reason is that this makes the class easier to subclass: you can override and specialize methods, but not instance variables. So, if I use the instance variable directly, a subclass cannot "hook" into those accesses.

Whereas if I use getter and setter methods, the subclass can override those (or only one of those).

Note: the example is too small and simple, so I had some real trouble coming up with a good name (there is not enough in the example to understand how the variable is used and what it means), so eventually, I just gave up, but you will see what I mean about using getters and setters:

class Example
  class NegativeNumberError < ArgumentError; end

  def initialize(numbers = [])
    self.numbers_backing = numbers.clone
  end

  def each(...)
    return enum_for(__callee__) unless block_given?
    numbers_backing.each(...)
    self
  end

  def <<(number)
    validate(number)
    numbers_backing << number
    self
  end

  private

  attr_accessor :numbers_backing

  def validate(number)
    raise NegativeNumberError unless number >= 0
  end
end

example = Example.new([1, 2, 3])
example.each.to_a # [1, 2, 3]
example << 4
example.each.to_a # [1, 2, 3, 4]
example << -1 # raise NegativeNumberError


来源:https://stackoverflow.com/questions/64379609/overriding-the-method-for-instance-variables

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!