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
.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
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
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
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.