Clarification on Getter Usage and Direct Initialization Requirement

Hi everyone,

I’m facing an issue with initializing an instance variable in Crystal and would appreciate some insights into why this is happening.

Here’s a simplified version of my code:


class A
end

class WrapperA
  def initialize(@a : A, @user_a : UserA)
  end
end

class UserA
  @a_wrapper : WrapperA

  def initialize(a : A)
    @a_wrapper = WrapperA.new(a, self)
  end
end

a = A.new
user_a = UserA.new(a)

When I compile it I get:

$ crystal test.cr 
Showing last frame. Use --error-trace for full trace.

In test.cr:10:3

 10 | @a_wrapper : WrapperA
      ^---------
Error: instance variable '@a_wrapper' of UserA was not initialized directly in all of the 'initialize' methods, rendering it nilable. Indirect initialization is not supported.

It appears that Crystal expects instance variables to be initialized directly within the initialize method. But if I explicitly declare @a_wrapper as a member variable (or use getter macro), the compiler still sees it as possibly nil because it’s indirectly initialized.

I can have a workwround for getter like this:

class UserA
  def initialize(a : A)
    @a_wrapper = WrapperA.new(a, self)
  end

  def a_wrapper : WrapperA
    @a_wrapper
  end
end

But suppose that this compilation error is not an expected behavior.
Thanks for any guidance or explanations on this behavior!

A simple solution might be:

class UserA
  @a_wrapper : WrapperA?

  def initialize(a : A)
    @a_wrapper = WrapperA.new(a, self)
  end
end

It is , but I need @a_wrapper not to be optional

@a_wrapper = WrapperA.new(a, self)

What does self mean here?

At this point, the initialization isn’t complete yet, but it’s already being passed into the method. . That’s why the compiler thinks @a_wrapper might be nil, and in fact, it’s probably something like nil at this stage.

This is just my guess. Let’s wait for someone who knows more to explain.

Related:

Using getter! or using getter with a block that raises will work: getter a_wrapper : WrapperA { raise "" }. This is because types that raise have a return type of NoReturn which can pass for all types.

1 Like

Use UserA.allocate seem like not work.

When you create a new instance, get Invalid memory access.

# ....

a = A.new
user_a = UserA.new(a)
p! user_a
user_a # => #<UserA:0x71bcfc003e60 @a_wrapper=#<WrapperA:0x71bcfc003e20 @a=#<A:0x71bcfc004fd0>, @user_a=#<UserA:0x71bcfc003e40 @a_wrapper=#<WrapperA:0x0 @a=Invalid memory access (signal 11) at address 0x8
[0x58d08ab2c736] *Exception::CallStack::print_backtrace:Nil +118 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab1a706] ~procProc(Int32, Pointer(LibC::SiginfoT), Pointer(Void), Nil) +310 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x71bcfc05c1d0] ?? +125056496026064 in /usr/lib/libc.so.6
[0x58d08abd497a] *WrapperA +378 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08abd4713] *UserA +387 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08abd49b6] *WrapperA +438 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08abd4713] *UserA +387 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab1b156] *p<UserA>:UserA +22 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab0aa74] __crystal_main +1092 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab69a66] *Crystal::main_user_code<Int32, Pointer(Pointer(UInt8))>:Nil +6 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab699da] *Crystal::main<Int32, Pointer(Pointer(UInt8))>:Int32 +58 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x58d08ab18116] main +6 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x71bcfc044e08] ?? +125056495930888 in /usr/lib/libc.so.6
[0x71bcfc044ecc] __libc_start_main +140 in /usr/lib/libc.so.6
[0x58d08ab0a555] _start +37 in /home/zw963/.cache/crystal/crystal-run-1.tmp
[0x0] ???

Devonte’s answer works.

user_a # => #<UserA:0x740a6943de60 @a_wrapper=#<WrapperA:0x740a6943de40 @a=#<A:0x740a6943efd0>, @user_a=#<UserA:0x740a6943de60 …>>>

However, both usage still make the @a_wrapper nilable, which seems not what the post want.

class A
end

class WrapperA
  def initialize(@a : A, @user_a : UserA)
  end
end

class UserA
  @a_wrapper = uninitialized WrapperA

  def initialize(a : A)
    @a_wrapper = WrapperA.new(a, self)
  end
end

a = A.new
user_a = UserA.new(a)
p! user_a
'self' was used before initializing instance variable '@a_wrapper', rendering it nilable

Above code can make @a_wrapper not nilable, but said by @kojix2 and links in @Devonte answer, the key point is, passing self into initialize is not possible.

Using getter! will make it nilable. Using getter with a raising block does not because NoReturn is considered the bottom type which passes everything. It is semantically the same as using uninitialized.

This is not the case. self can be used in initialize provided all instance variables are instantiated before self is accessed. This is intentional by the compiler to prevent things like invalid memory access before a type is fully instantiated.

class A
end

class WrapperA
  def initialize(@a : A, @user_a : UserA)
  end
end

class UserA
  getter a_wrapper : WrapperA { raise "" }

  def initialize(a : A)
    @a_wrapper = WrapperA.new(a, self)
    p!(typeof(@a_wrapper))  # typeof(@a_wrapper) # => (WrapperA | Nil)
  end
end

a = A.new
user_a = UserA.new(a)
p! user_a

It is semantically the same as using uninitialized.

It’s not same, because my code not work, it raise a error.

'self' was used before initializing instance variable '@a_wrapper'

This is not the case. self can be used in initialize provided all instance variables are instantiated before self is accessed

Reasonable, but this is not the case, @a_wrapper still not initialize when self was used.

Your code not raise error when running, i guess because both a and self all nilable, which delay the initialize of @a_wrapper.

Getters with lazy evaluation initialize the value to nil, so the ivar will always be a nilable union. Regardless of the output type of the block.

1 Like

Maybe this works as a workaround, but note that it only satisfies the compiler because without a direct declaration, @a_wrapper is implicitly declared as nilable (typeof(@a_wrapper) # => WrapperA | Nil). You could achieve the same with an explicit declaration as @a_wrapper : WrapperA?.

1 Like

As was mentioned before in multiple comments, this is an intentional feature to prevent inconsistent state. At the time the self reference is passed to WrapperA, self is not fully initialized because @a_wrapper has not been assigned. The compiler notices that and prevents you from passing around a reference to an object that’s not fully initialized.

It’s a vicious circle: @a_wrapper depends on WrapperA.new, which depends on self which depends on @a_wrapper.
There are ways to break that and convince the compiler to still let you pass a reference. For example, you could declar @a_wrapper as uninitialized. But you’ll need to take good care not to break anything with this. The type system won’t be able to protect you. This is always going to be unsafe, so it’s not really recommended.

A common and type safe solution for initializing objects with circular references is late assignments to nilable properties. The getter! and property! macros can help with that.

class WrapperA
  property! user_a : UserA
  def initialize(@a : A)
  end
end

class UserA
  def initialize(a : A)
    @a_wrapper = WrapperA.new(a)
    @a_wrapper.user_a = self
  end
end
2 Likes