Ruby nil-like object

2019-04-19 22:44发布

How can I create an Object in ruby that will be evaluated to false in logical expressions similar to nil?

My intention is to enable nested calls on other Objects where somewhere half way down the chain a value would normally be nil, but allow all the calls to continue - returning my nil-like object instead of nil itself. The object will return itself in response to any received messages that it does not know how to handle and I anticipate that I will need to implement some override methods such as nil?.

For example:

fizz.buzz.foo.bar

If the buzz property of fizz was not available I would return my nil-like object, which would accept calls all the way down to bar returning itself. Ultimately, the statement above should evaluate to false.

Edit:

Based on all the great answers below I have come up with the following:

class NilClass
  attr_accessor :forgiving
  def method_missing(name, *args, &block)
    return self if @forgiving
    super
  end
  def forgive
    @forgiving = true
    yield if block_given?
    @forgiving = false
  end
end

This allows for some dastardly tricks like so:

nil.forgiving {
    hash = {}
    value = hash[:key].i.dont.care.that.you.dont.exist
    if value.nil?
        # great, we found out without checking all its parents too
    else
        # got the value without checking its parents, yaldi
    end
}

Obviously you could wrap this block up transparently inside of some function call/class/module/wherever.

4条回答
太酷不给撩
2楼-- · 2019-04-19 23:17

There is a principle called the Law of Demeter [1] which suggests that what you're trying to do is not good practice, as your objects shouldn't necessarily know so much about the relationships of other objects.

However, we all do it :-)

In simple cases I tend to delegate the chaining of attributes to a method that checks for existence:

class Fizz
  def buzz_foo_bar
    self.buzz.foo.bar if buzz && buzz.foo && buzz.foo.bar
  end
end

So I can now call fizz.buzz_foo_bar knowing I won't get an exception.

But I've also got a snippet of code (at work, and I can't grab it until next week) that handles method missing and looks for underscores and tests reflected associations to see if they respond to the remainder of the chain. This means I don't now have to write the delegate methods and more - just include the method_missing patch:

module ActiveRecord
  class Base
    def children_names
      association_names=self.class.reflect_on_all_associations.find_all{|x| x.instance_variable_get("@macro")==:belongs_to}
      association_names.map{|x| x.instance_variable_get("@name").to_s} | association_names.map{|x| x.instance_variable_get("@name").to_s.gsub(/^#{self.class.name.underscore}_/,'')}
    end

    def reflected_children_regex
      Regexp.new("^(" << children_names.join('|') << ")_(.*)")
    end

    def method_missing(method_id, *args, &block)
      begin
        super
      rescue NoMethodError, NameError
        if match_data=method_id.to_s.match(reflected_children_regex)
          association_name=self.methods.include?(match_data[1]) ? match_data[1] : "#{self.class.name.underscore}_#{match_data[1]}"
          if association=send(association_name)
            association.send(match_data[2],*args,&block)
          end
        else
          raise
        end
      end
    end
  end
end

[1] http://en.wikipedia.org/wiki/Law_of_Demeter

查看更多
甜甜的少女心
3楼-- · 2019-04-19 23:19

As far as I'm aware there's no really easy way to do this. Some work has been done in the Ruby community that implements the functionality you're talking about; you may want to take a look at:

The andand gem is used like this:

require 'andand'
...
fizz.buzz.andand.foo.andand.bar
查看更多
兄弟一词,经得起流年.
4楼-- · 2019-04-19 23:25

You can modify the NilClass class to use method_missing() to respond to any not-yet-defined methods.

> class NilClass
>   def method_missing(name)
>     return self
>   end
> end
=> nil
> if nil:
*   puts "true"
> end
=> nil
> nil.foo.bar.baz
=> nil
查看更多
三岁会撩人
5楼-- · 2019-04-19 23:27

This is a pretty long answer with a bunch of ideas and code samples of how to approach the problem.

try

Rails has a try method that let's you program like that. This is kind of how it's implemented:

class Object
  def try(*args, &b)
    __send__(*a, &b)
  end
end

class NilClass        # NilClass is the class of the nil singleton object
  def try(*args)
    nil
  end
end

You can program with it like this:

fizz.try(:buzz).try(:foo).try(:bar)

You could conceivably modify this to work a little differently to support a more elegant API:

class Object
  def try(*args)
    if args.length > 0
      method = args.shift         # get the first method
      __send__(method).try(*args) # Call `try` recursively on the result method
    else
      self                        # No more methods in chain return result
    end
  end
end
# And keep NilClass same as above

Then you could do:

fizz.try(:buzz, :foo, :bar)

andand

andand uses a more nefarious technique, hacking the fact that you can't directly instantiate NilClass subclasses:

class Object
  def andand
    if self
      self
    else               # this branch is chosen if `self.nil? or self == false`
      Mock.new(self)   # might want to modify if you have useful methods on false
    end
  end
end

class Mock < BasicObject
  def initialize(me)
    super()
    @me = me
  end
  def method_missing(*args)  # if any method is called return the original object
    @me
  end
end

This allows you to program this way:

fizz.andand.buzz.andand.foo.andand.bar

Combine with some fancy rewriting

Again you could expand on this technique:

class Object
  def method_missing(m, *args, &blk)        # `m` is the name of the method
    if m[0] == '_' and respond_to? m[1..-1] # if it starts with '_' and the object
      Mock.new(self.send(m[1..-1]))         # responds to the rest wrap it.
    else                                    # otherwise throw exception or use
      super                                 # object specific method_missing
    end
  end
end

class Mock < BasicObject
  def initialize(me)
    super()
    @me = me
  end
  def method_missing(m, *args, &blk)
    if m[-1] == '_'  # If method ends with '_'
      # If @me isn't nil call m without final '_' and return its result.
      # If @me is nil then return `nil`.
      @me.send(m[0...-1], *args, &blk) if @me 
    else 
      @me = @me.send(m, *args, &blk) if @me # Otherwise call method on `@me` and
      self                                  # store result then return mock.
    end
  end
end

To explain what's going on: when you call an underscored method you trigger mock mode, the result of _meth is wrapped automatically in a Mock object. Anytime you call a method on that mock it checks whether its not holding a nil and then forwards your method to that object (here stored in the @me variable). The mock then replaces the original object with the result of your function call. When you call meth_ it ends mock mode and returns the actual return value of meth.

This allows for an api like this (I used underscores, but you could use really anything):

fizz._buzz.foo.bum.yum.bar_

Brutal monkey-patching approach

This is really quite nasty, but it allows for an elegant API and doesn't necessarily screw up error reporting in your whole app:

class NilClass
  attr_accessor :complain
  def method_missing(*args)
    if @complain
      super
    else
      self
    end
  end
end
nil.complain = true

Use like this:

nil.complain = false
fizz.buzz.foo.bar
nil.complain = true
查看更多
登录 后发表回答