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
I was expecting:
[B]
But the actual output was:
Error: undefined method 'subclasses' for A.class
😢
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 methodsubclasses
implemented like this:
class Class
def self.subclasses
{{ @type.subclasses }}
end
end
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
The example outputs:
[B, C]
[E]
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
The example outputs:
Error: method B.subclasses must return Array(B.class) but it is returning Array(E.class)
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)
The output:
Array(A.class | B.class)
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)
The output:
Array(A.class)
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)
The output:
Array(A.class)
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
The Error output:
Error: method B.subclasses must return Array(B.class) but it is returning Array(E.class)
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 returnedArray
, and they all have the same common upperclassA
. So the returned type isArray(A.class)
, which matches the specification of the method. - It does raise an error on
B.subclasses
because there is only one elementE
, and so the type isArray(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
The output:
[B, C]
[E]
And it's working again! 🎉
Solution 2 🤯
Johannes proposed this other solution:
Open the class
Class
and define a new methodsubclasses
implemented like this:
class Class
def self.subclasses : Array(self.class)
{% begin %}
[{{ @type.subclasses.join(",").id }}] of self.class
{% end %}
end
end
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]
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
The output: The output: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
[B, C]
[B, E, C, D]
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
[B, C]
[B, E, C, D]
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)