Crystal has two variants for creating constructors: The basic one is #initialize
. The compiler automatically creates a corresponding .new
method. If you want to define alternative constructor behaviour, you can either overload #initialize
or .new
. Or you can define any other class method and delegate to an existing constructor (.new
). Conceptually, there’s not much difference whether class methods are called .new
or have a different name.
But there is a difference whether a constructor overload is a separate #initialize
or a class method.
As far as I am aware, it is generally recommended to use as few #initialize
methods as possible (typically just a single one) and let other constructors delegate to that. This avoids repetition, avoids unexpected override issues in subclasses and in general should help focus the behaviour: main constructors initializes the instance, alternative constructors transform the arguments.
When inheritance comes into play, this gets more complicated though: Overridden class methods can only delegate to other class methods, including super
- which is also a class method. This means, when overriding a class method constructor of a parent type, that override cannot assign additional instance methods introduced by the child type.
This is probably best exercised with an example. The following snippet shows simple class inheritance with a couple of constructor methods, where one delegates to the other.
class Foo
def self.new(x : Int32)
new(x.to_s)
end
def initialize(@x : String)
puts x
end
end
class Bar < Foo
end
Bar.new(1)
Bar
inherits the constructor methods. That’s all good.
If the child class defines additional instance variables that need to be initialized, we need to override the #initialize
method. That’s also fine. But the class method override no longer works because the variables won’t get initialized in the parent type’s constructor.
To fix this, we have to duplicate Foo.new(Int32)
to account for the additional instance variable.
class Baz < Foo
@y : Bool
def self.new(x : Int32, y)
new(x.to_s, y)
end
def initialize(@x : String, @y)
puts x
end
end
Baz.new(1, true)
But duplication is
Alternatively, we could use #initialize
overloads instead of class methods:
class Foo
def initialize(x : Int32)
initialize(x.to_s)
end
def initialize(@x : String)
puts x
end
end
class Bar < Foo
@y : Bool
def initialize(x : Int32, @y)
super(x)
end
end
Bar.new(1, true)
With this, we don’t need to duplicate the behaviour of Foo#initialize(Int32)
, it can be reached with super
and we can still assign additional instance variables directly.
A practical example of this is with Socket
, which defines a constructor that is used by subclasses TCPSocket
and UnixSocket
:
The problem is, this needs a change in the parent class. When writing the parent class, we would need to know whether child classes want to delegate to super
as a #intialize
.
One option would be to just always overload #initialize
instead of .new
for defining alternative constructors. Not sure if that’s a good option, because it also has its issues, for example when defining one child class #initialize
overrides all parent class #intialize
.
I don’t have any clear conclusion or actionable proposal on this, just wanted to share my thoughts and see if others had experiences with that and if there is enough concerns that we should consider thinking about possible improvements.