Home > Enterprise >  Ruby: How do you access a subclass constant inside the parent class when defining a struct?
Ruby: How do you access a subclass constant inside the parent class when defining a struct?

Time:06-14

class Parent
  VALID_COLORS = ["blue"]
  
  MyStruct = Struct.new(:color) do
    def initialize(color:)
      raise "invalid color" unless self.class::VALID_COLORS.include?(color)
      super(color: color)
    end
  end
end

class Subclass < Parent
  VALID_COLORS = ["red"]

  def call
    MyStruct.new(color: "red")
  end
end

> Subclass.new.call
=> uninitialized constant Parent::MyStruct::VALID_COLORS

How can I access the correct VALID_COLORS constant in my struct initialization? Normally self.class::CONSTANT would allow you to access the subclass's constant while in the parent class, but it looks like self.class will bring up the struct instead while inside the struct. There are some workarounds I've thought of like 1) adding a new parameter to the struct to take in valid colors; or 2) setting VALID_COLORS as an instance variable instead of class variable. But I'm curious if there's a way to make my current implementation work as intended?

CodePudding user response:

I'm curious if there's a way to make my current implementation work as intended?

Since you asked for it, let's give it a try.

First of all, your Parent::MyStruct already isn't working:

Parent::MyStruct.new(color: 'blue')
#=> NameError: uninitialized constant Parent::MyStruct::VALID_COLORS

That's because VALID_COLORS is defined in Parent, not in Parent::MyStruct. To dynamically reference the parent's namespace (in terms of nesting), Rails has module_parent:

Parent::MyStruct.module_parent #=> Parent

Applied to your code: (I've also added keyword_init: true to make the struct take actual keyword arguments, see the docs for Struct.new)

class Parent
  VALID_COLORS = ["blue"]

  MyStruct = Struct.new(:color, keyword_init: true) do
    def initialize(color:)
      raise "invalid color" unless self.class.module_parent::VALID_COLORS.include?(color)
      #                                       ^^^^^^^^^^^^^
      super
    end
  end
end

Which gives:

Parent::MyStruct.new(color: 'blue')
#=> #<struct Parent::MyStruct color="blue">

Now that we've fixed that one, let's try to create a struct through the subclass:

Subclass::MyStruct.new(color: 'red')
#=> RuntimeError: invalid color

This error occurs, because Subclass::MyStruct is in fact Parent::MyStruct:

Subclass::MyStruct #=> Parent::MyStruct

To fix this, Subclass needs its very own MyStruct. We can solve it by creating a new class which inherits from Parent::MyStruct:

class Subclass < Parent
  VALID_COLORS = ["red"]

  MyStruct = Class.new(Parent::MyStruct)
end

The Parent:: prefix is actually optional, but MyStruct = Class.new(MyStruct) looks too confusing.

The above gives:

struct = Subclass::MyStruct.new(color: 'red')
#=> #<struct Subclass::MyStruct color="red">

struct.is_a?(Subclass:MyStruct) #=> true
struct.is_a?(Parent::MyStruct)  #=> true

If you really want to use this approach, you could even move the dynamic class creation into Parent using the inherited callback:

class Parent
  def self.inherited(subclass)
    subclass.const_set(:MyStruct, Class.new(self::MyStruct))
  end

  # ...
end

CodePudding user response:

A VALID_COLORS is defined inside Parent and inside Subclass (which is irrelevant here). The initializer of Parent::MyStruct also tries to access a constant of this name, but MyStruct does not have a VALID_COLORS.

I don't know what exactly you want to achieve, but depending on what you want, you can either do a

... unless Parent::VALID_COLORS.include?(color)

or a

... unless Subclass::VALID_COLORS.include?(color)

or provide another VALID_COLORS constant for MyStruct.

CodePudding user response:

The issue here is that the MyStruct constant is literally a reference to the object defined on Parent. When Parent is subclassed, the MyStruct object is not redefined, the reference is just copied. We can verify this with the following:

irb(main):014:0> Parent::MyStruct.object_id
=> 70326616692980
irb(main):015:0> Subclass::MyStruct.object_id
=> 70326616692980

To get around this, you could create a method that initializes the class object on the fly:

class Parent
  private

  def my_struct
    @my_struct ||= Struct.new(:color) do
      def initialize(color:)
        raise "invalid color" unless self.class::VALID_COLORS.include?(color)
        super(color: color)
      end
    end
  end
end

If you're willing to do a bit more work, making color it's own class instead of a struct would be a lot more robust:

class Color
  attr_accessor :color

  def initialize(color: color, valid_colors: Parent:: VALID_COLORS)
    raise "invalid color" unless valid_colors.include?(color)
    
    @color = color
  end
end

class Parent
  def self.color(color)
    Color.new(color: color, valid_colors: self.class::VALID_COLORS)
  end
end

Or you could try to investigate the caller to load the correct constant (I highly advise against this):

MyStruct = Struct.new(:color) do
  def initialize(color:)
    caller_path = caller_locations(1,1)[0].absolute_path
    valid_color_lib = {
      "parent.rb" => Parent::VALID_COLORS,
      "subclass.rb" => Subclass::VALID_COLORS
    }[caller_path]

    raise "invalid color" unless valid_color_lib.include?(color)
    super(color: color)
  end
end
  • Related