Home > Software engineering >  Dynamically remove mixin ancestor from class
Dynamically remove mixin ancestor from class

Time:02-10

I am trying to develop a simple card game console application in ruby as a pet project. One of the interactions I would like to add to this card game is something along the lines of "During your turn, you can buy cards as if they cost X less". Now, I thought about modelling this behaviour with the help of mixin decorators. Assuming the following class:

class Card
  attr_reader :cost
  
  def initialize(cost)
    self.cost = cost
  end

  def play(*_args)
    raise NotImplementedError, "Override me!"
  end

  private

  attr_writer :cost
end

I thought a simple solution would be something like this:

class CardThatDiscounts < Card
  def play(*_args)
    @mod = Module.new do
      def cost
        super - 1
      end
    end
    
    Card.prepend(@mod)
  end
end

This would give me the flexibility to target either the class itself, or specific instances of the cards. However, I am unsure of how to reverse this effect, at the end of the turn. I saw a few questions similar to this one, the answers of which said something along the lines of:

  1. Remove the defined method manually.

This approach won't really work for me, since I am decorating existing methods instead of adding new ones. I could maybe rename the existing method when I prepend the module, and then return it back, but what happens in case I want to chain multiple such decorators?

  1. Use gem mixology

Mixology is an old gem, and seems no longer maintained. Though, according to the documentation, it is exactly what I need. However, the implementation mostly deals with ruby internals, that I unfortunately do not understand. Is it not possible to modify a class ancestor chain from within Ruby, or do I need c/Java extension for this?

I considered other alternatives, such as SimpleDelegator or DelegateClass, but both of these require that I instantiate new objects, and then somehow replace references to existing Card objects with these new wrapped objects, and back again, at the end of turn. Which seemed a bit more involved than modifying the ancestry chain directly.

I suppose my question has 2 parts. Is it possible to remove a specific ancestor from a class's ancestor chain using pure ruby (since mixology gem already suggests it is possible to do with c/Java extensions)? If not, what would be a suitable workaround to get a similar behaviour?

CodePudding user response:

What you are trying to achieve is a very bad pattern. You should almost never use prepend or include dynamically, but model your code around the concepts you (and people possibly reading your code) do understand.

What you probably want to do is to create (some kind of) a Delegator called CardAffectedByEnvironment - and then instead of doing Card.new(x), you will always do CardAffectedByEnvironment.new(Card.new(x), env), where env will keep all your state changes, or add a method real_cost that would calculate things based on your cost and environment and use this method.

Below is a code with CardAffectedByEnvironment that would maybe describe better how I would assume it would work:

class Environment
  def initialize
    @modifiers = []
  end

  attr_reader :modifiers

  def next_round
    @modifiers = []
  end

  def modifiers_for(method)
    @modifiers.select { |i| i.first == method }
  end
end

class CardAffectedByEnvironment
  def initialize(card, env)
    @card, @env = card, env
  end

  # Since Ruby 3.0 you can write `...` instead of `*args, **kwargs, &block`
  def method_missing(method, *args, **kwargs, &block)
    value = @card.public_send(method, *args, **kwargs, &block)
    @env.modifiers_for(method).each do |_, modifier|
      value = modifier.call(value)
    end
    value
  end
end

class Joker
  def cost
    10
  end
end

env = Environment.new
card = CardAffectedByEnvironment.new(Joker.new, env)

p card.cost # => 10

env.modifiers << [:cost, ->(i) { i - 1 }]

p card.cost # => 9

env.next_round

p card.cost # => 10
  • Related