Constructor variants and inheritance

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 :-1: :cry:

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.

2 Likes

I think of “new” as “allocate then initialize”, so unless I’m changing how allocation works, I never touch “new”; I basically only ever overload initialize methods.

When writing the parent class, we would need to know whether child classes want to delegate to super as a #intialize .

I think I generally always assume that subclasses might want to call super.

I suppose I didn’t explicitly point this out, so for clarification: the approach overriding #initialize shows problems when subclassing is involved. If you define an #initialize in a child class, it overrides all initializers of the parent class:

class Foo
  def initialize(x : String)
  end
end

class Bar < Foo
  def initialize(x : Int32)
    super x.to_s
  end
end

Bar.new "" # Error: no overload matches 'Bar.new' with type String

This is the main reason for overloading .new instead of #initialize.

Shouldn’t Foo#initialize(x : String) be available in Bar, but have a different method signature than Bar.initialize(x : Int32), so shouldn’t error?

Ok, that’s unexpected then, and begs the question as to why it overrides all initializers as opposed to only the ones defined? (I’m sure there are reasons, but they are not clear)

Probably because it would allow the child’s ivars to go uninitialized if the only initializer was on the parent.

I think the reason is like @Blacksmoke16 said, with an example is easier:

class Foo
  @str : String
  def initialize(@str)
  end
end

class Bar < Foo
  @num : Int32
  def initialize(@num)
  end
end

So… if the initializers of Foo were still available to initialize Bar, how would Crystal know how to initialize @num when someone codes Foo.new("hi")?

It wouldn’t know how, but I would expect Crystal to raise an error in the same way it raises an error for the following code where it also doesn’t know how:

class Foo
  @str : String
  @num : Int32

  def initialize(@str)
  end
end
 3 | @num : Int32
     ^---
Error: instance variable '@num' of Foo was not initialized directly in all of the 'initialize' methods, rendering it nilable. Indirect initialization is not supported.
1 Like