Search code examples
rubyfunctional-programmingmetaprogramming

How to modify the behavior of method definitions themselves in Ruby


The Setup

I recently started working with Ruby after writing mostly very functional/compositional JS and some point free clojure.

I've learned that Ruby is very "open" to modification or extension. Neato.

I set myself a challenge, and promptly failed to make any progress towards it. Likely this is because I'm still in the "Don't know what terms to search" stage of learning Ruby.

My Goal

Write a module such that when used it does two things...

  1. Causes all methods defined on the module/class to become static
  2. Causes all methods defined on the module/class to return a lambda of themselves instead

Concrete Examples

For rule 1 this would look something like

class TestClass
  include ThingIWannaMake
  def something
    puts "hello world"
  end
end

would effectively be

class TestClass
  def self.something
    puts "hello world"
  end
end

while for rule 2 this

class TestClass
  include ThingIWannaMake
  def something(a)
    puts a
  end
end

would mean the same as

class TestClass
  def self.something
    -> a { puts a }
  end
end

Leading to a final, ideal, output (for the second input) being...

class TestClass
  def self.something
    -> a { puts a }
  end
end

The Question(s)

This task was primarily taken on as a learning exercise in Ruby so my primary interest is in the terms and tutorials that would teach my how I could, or why I could not, achieve this goal or another one like it.

Edits/Responses

While this question was wonderfully answered (thank you!) the majority of feedback was negative. Please allow me to address some of the points here, for future readers who may stumble across this as well as in response to the comments themselves.

  1. Word Choice - many of the words and terms I used were/are incorrect. Most grievous was the use of "static [method]". Thanks to the comments below I now know that a much more accurate understanding of the concept within the Ruby world would be a "class [method]". My mistake there is an extension of my place as a "Ruby foreigner" still learning both languages and concepts. I believe that this language mistake is indicative of, and likely very common to, new Ruby users. As such I will leave the mistake, in light of the corrections below, in the hope that my failures teach future learners who follow.
  2. The Goal Was Bad - While I don't know if a comment response is any kind of place to change someone's core beliefs about what is or is not "good programming" I may be able to at least provide some empathy for my position by explaining some of the concepts I was using as points of reference. Closest is monkey-patching in javascript. Most commonly used (for me) are macros in clojure. But perhaps most applicable are reader macros from common lisp. With this task I was trying, to some extent, to "calibrate" my expectations for Ruby relative to these more familiar well liked tools.
  3. added a code example of all parts working in unison.

Thank you again for the answers! Hopefully these clarifications make this at least a less frustrating question to read.


Solution

  • Oh this is going to be terrible, but you asked for it!

    Here's what we're making work:

    class TestClass
      include Lambdifier
      def something
        puts "hello world"
      end
    end
    TestClass.something.call
    # hello world
    # => nil
    
    1. The first conundrum to solve is the fact that at the point when Lambdifier is included there are no methods defined yet. Ruby's definitions run sequentially. So we're going to have to run a piece of code whenever a method is added to the class. Conveniently there's a Ruby hook method_added that does just that.

    2. Another issue would be that you technically can unbind methods from their original owners and rebind them to new ones via instance_method and bind, but Ruby still requires the new owner to be of the same class or its descendant. We can work around this by creating an owner we need from the original class, effectively making it a singleton. And Ruby's standard library provides this solution already, so we'll make use of it and not reinvent the wheel. And since the methods we need are already bound to a valid receiver, rebinding won't be necessary.

      One could say that it's a major deviation as this doesn't not attach instance methods to their respective class, but doing so breaks the type system, because an object's class is not guaranteed to be type-compatible to its instances (although realistically, even instances of subclasses may not be), so the fact that it's not allowed is probably for the better.

      (There is at least one exception to this rule, Class, which is an instance of itself... but there isn't much you can do with it, as it's treated by the language in a rather special way.)

    require "singleton"
    
    module Lambdifier
      def self.included(base)
        base.include ::Singleton
        base.extend ClassMethods
      end
    
      module ClassMethods
        def method_added(name)
          prior_method = instance.method(name)
          define_singleton_method(name) { prior_method }
        end
      end
    end
    
    class TestClass
      include Lambdifier
      def something
        puts "hello world"
      end
    end
    
    TestClass.something.call
    # hello world
    # => nil
    

    A few remarks:

    • The closest thing to "static methods" in Ruby are "singleton methods". They're essentially methods, but are defined on a value's "singleton class", a lazily defined class (in that it doesn't exist until accessed) just for this value. And define_singleton_method is essentially singleton_class.define_method. And the same thing as doing define_method or def in a class << self block.

    • The return values of methods aren't technically lambdas, but they "quack like lambdas", in that they're callables (have a call method). Most prominent callables in Ruby are procs and lambdas, but there are neither — these are Methods:

      TestClass.something
      # => #<Method: TestClass#something>
      
    • The instance methods are still there in their original form! You can "undefine" them with undef_method if you need to though. It replaces a method with a "tombstone" inside the class which causes Ruby to stop going up the ancestor chain and raise NoMethodError.

      TestClass.instance_methods(false)
      # => [:something]
      TestClass.undef_method(:something) # Where to insert? Left as an exercise :)
      TestClass.send(:new).something # Singleton hides .new...
      # !> NoMethodError: undefined method `something' for #<TestClass:...>
      TestClass.something.call
      # hello world
      # => nil
      

      Mind you, if you inline prior_method this won't work, as class methods will be looking up their counterparts on instance on every call.

      Could you make instance methods return "lambdas of themselves" before making them "static"? Hm... to return a "lambda" you're not going to need arguments, so you'd have to change the arity to 0, which would make the interface incompatible (even more so, because return type change already happened). So probably not.