DEV Community

Cover image for Subclasses
Franciscello
Franciscello

Posted on

Subclasses

The other day I was writing some code examples in Crystal and I wanted to print the subclasses for a given Class. In Smalltalk we would've used the message subclasses and so I tried:

class A; end
class B < A; end

p A.subclasses
Enter fullscreen mode Exit fullscreen mode

I was expecting:

[B]
Enter fullscreen mode Exit fullscreen mode

But the actual output was:

Error: undefined method 'subclasses' for A.class
Enter fullscreen mode Exit fullscreen mode

😢

I was like: ok, no problem! Let's search in Crystal's API for such method ... 📖 ... oh! here it is! #subclasses.

The method exists so ...

So why didn't it work? 🤔
The method #subclasses exists in Macro land meaning it's available at compile time but we were trying to use it at runtime.

Note: Macro land sounds like a whole new world and let me be honest: It is! And it's a powerful feature in Crystal!

So at that point I had a new challenge: implement subclasses to be used as we originally intended, and of course the implementation would be using Macros. I discussed different solutions with some of the core team members. Here are two possible and interesting solutions:

Solution 1 🤯

Beta proposed the following solution:

Open the class Class and define a new method subclasses implemented like this:

class Class
  def self.subclasses
    {{ @type.subclasses }}
  end
end
Enter fullscreen mode Exit fullscreen mode

Now let's use the new method with the following example:

class A
end

class B < A
end

class C < A
end

class D < C
end

class E < B
end

p A.subclasses, B.subclasses
Enter fullscreen mode Exit fullscreen mode

The example outputs:

[B, C]
[E]
Enter fullscreen mode Exit fullscreen mode

It worked! 🤓🎉

But now let's try to be more specific on the method's type. We are going to say that the method returns an Array of objects of the same type as the class for which we are defining the new method:

class Class
  def self.subclasses : Array(self.class)
    {{ @type.subclasses }}
  end
end
Enter fullscreen mode Exit fullscreen mode

The example outputs:

Error: method B.subclasses must return Array(B.class) but it is returning Array(E.class)
Enter fullscreen mode Exit fullscreen mode

No! What happened?!🤔
The compiler is complaining about B.subclasses returning Array(E.class) instead of Array(B.class). Well, the compiler is right! But, why it didn't complain about A.subclasses?

Well, let's make a pause and write some examples:

The type of an array with classes

Suppose we have classes A and B and an Array with these two classes, what would be the type of said Array:

class A; end
class B; end

arr = [A, B]
p typeof(arr)
Enter fullscreen mode Exit fullscreen mode

The output:

Array(A.class | B.class)
Enter fullscreen mode Exit fullscreen mode

The type of Array is Array (of course) and the type of its content is the union of the type of A and B.

And now, let's redefine B inheriting from A:

class A; end
class B < A; end

arr = [A, B]
p typeof(arr)
Enter fullscreen mode Exit fullscreen mode

The output:

Array(A.class)
Enter fullscreen mode Exit fullscreen mode

Oh! so when the union type can be "merged" to a common type then Crystal will do that "type merge".

With this hypothesis in mind, let's write a little modification of the previous example:

class A; end
class B < A; end
class C < A; end

arr = [B, C]
p typeof(arr)
Enter fullscreen mode Exit fullscreen mode

The output:

Array(A.class)
Enter fullscreen mode Exit fullscreen mode

Exactly what we were expecting! 🤓🎉


Now, let's go back to our main example:

class Class
  def self.subclasses : Array(self.class)
    {{ @type.subclasses }}
  end
end

class A
end

class B < A
end

class C < A
end

class D < C
end

class E < B
end

p A.subclasses, B.subclasses
Enter fullscreen mode Exit fullscreen mode

The Error output:

Error: method B.subclasses must return Array(B.class) but it is returning Array(E.class)
Enter fullscreen mode Exit fullscreen mode

Now it's clear what's going on:

  • It doesn't raise an error on A.subclasses because there are more than one element in the returned Array, and they all have the same common upperclass A. So the returned type is Array(A.class), which matches the specification of the method.
  • It does raise an error on B.subclasses because there is only one element E, and so the type is Array(E.class) (it doesn't try to find an upperclass) and so the returned type doesn't fit the specification.

The solution would be to tell the compiler to treat each element as its parent type:

class Class
  def self.subclasses : Array(self.class)
    {{ @type.subclasses }}.map(&.as(self.class))
  end
end

class A
end

class B < A
end

class C < A
end

class D < C
end

class E < B
end

p A.subclasses, B.subclasses
Enter fullscreen mode Exit fullscreen mode

The output:

[B, C]
[E]
Enter fullscreen mode Exit fullscreen mode

And it's working again! 🎉

Solution 2 🤯

Johannes proposed this other solution:

Open the class Class and define a new method subclasses implemented like this:

class Class
  def self.subclasses : Array(self.class)
    {% begin %}
      [{{ @type.subclasses.join(",").id }}] of self.class
    {% end %}
  end
end
Enter fullscreen mode Exit fullscreen mode

It also worked! 🤩

The difference between both solutions

The first one executes the "type conversion" at runtime while the second one specifies the type of the returned Array at compile time (when expanding the macro) without the need to "convert" each element at runtime.

Print all the subclasses 💪

Now, here is another new challenge: we want to define a new method #all_subclasses. For the previous example, this new method should return:

A.all_subclasses # => [B, E, C, D]
Enter fullscreen mode Exit fullscreen mode

As we can see, it should return the direct subclasses of A (as before) and the subclasses of the subclasses (recursively). Meaning we should return the possible paths starting from A:

A -> B -> E
A -> C -> D
Enter fullscreen mode Exit fullscreen mode

Here is the solution:

Based on Solution 1:

class Class
  def self.subclasses : Array(self.class)
    {{ @type.subclasses }}.map(&.as(self.class))
  end

  def self.all_subclasses : Array(self.class)
    {{ @type.all_subclasses }}.map(&.as(self.class))
  end
end

class A
end

class B < A
end

class C < A
end

class D < C
end

class E < B
end

p A.subclasses
p A.all_subclasses
Enter fullscreen mode Exit fullscreen mode

The output:

[B, C]
[B, E, C, D]
Enter fullscreen mode Exit fullscreen mode

Based on Solution 2:

class Class
  def self.subclasses : Array(self.class)
    {% begin %}
      [{{ @type.subclasses.join(",").id }}] of self.class
    {% end %}
  end

  def self.all_subclasses : Array(self.class)
    {% begin %}
      [{{ @type.all_subclasses.join(",").id }}] of self.class
    {% end %}
  end
end

class A
end

class B < A
end

class C < A
end

class D < C
end

class E < B
end

p A.subclasses
p A.all_subclasses
Enter fullscreen mode Exit fullscreen mode

The output:

[B, C]
[B, E, C, D]
Enter fullscreen mode Exit fullscreen mode

Farewell and see you later

Let's recap:

We have extended the Class class implementing the new methods subclasses and all_subclasses using macros.

Hope you enjoyed it! 😃

Top comments (0)