Folks, I ran into a strange compilation error I am unable to solve
The following code attempts to isolate it:
require "json"
class Base
include JSON::Serializable
property name : String = "default"
end
class Extended < Base
end
map = {
"key1" => Base,
# "key2" => Extended,
}
puts map.class.inspect
puts map["key1"].new(JSON::PullParser.new("{}")).inspect
# puts map["key2"].new(JSON::PullParser.new("{}")).inspect
It fails with:
In /usr/local/Cellar/crystal/1.12.1_1/share/crystal/src/json/serialization.cr:181:9
181 | def initialize(*, __pull_for_json_serializable pull : ::JSON::PullParser)
^---------
Error: instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable
but it compiles if you either
make @name nilable (which is not what I want) or
comment out the sublass (Extended) definition (what I dont want either since the actual code has a whole bunch of subclasses here)
include JSON::Serializable removes the default argless constructor
Changing any of these things will resolve your issue. I guess the tl;dr is the compiler just can’t prove that @name will be initialized in this context with how you have things setup.
Don’t use a hash like this
Make Base abstract
Just define an actual constructor within Base. E.g. def initialize(@name : String); end
I can’t imagine this is working as intended, tbh. The error message says instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable, but that isn’t true. That ivar isn’t nilable regardless of whether it’s initialized to a non-nil value in that constructor or any other in any descendant class because it has a default non-nil value in the base class.
If you omit the hash it works just fine:
require "json"
class Base
include JSON::Serializable
property name : String = "default"
end
class Extended < Base
end
p! Base.from_json("{}")
p! Extended.from_json("{}")
Using a hash like this is pretty reasonable, too. I use them as type maps in a lot of my own libraries.
By “working as intended” I meant that there could be a state in which this error is valid given the current rules of the compiler. Feels like sometime @HertzDevil would know. To us it doesn’t seem that way, but .
I think it does matter because having structs requires all the parent types to be abstract, thus entirely preventing this scenario where the typing is reduced to Base+ vs Base.class.
I tried to investigate further on this, but all Ive seen is even more irritating … It appears to me that all the magic that makes crystal so elegant is really complicated voodoo stuff!
I can’t imagine, that what I am trying to do hasn’t been done before: pick the right type for deserializing some json.
Does anyone have the one enlightening hint? Please!
Can you share some more context around your use case? Like does the JSON data itself have a discriminator key? Or are you determining it outside of the data itself? or?
sure:
Its an attempt to refactor an existing app which was written in python and js. My json backed types are issues which indicate some kind of problem in a monitoring system. An issue is alwasy tied to an asset and has a timestamp of first appearance and a timestamp of last confirmation. Beside that every type of issue is different (carries different information). So there is this type structure Issue base class and lots of derived concrete issue classes. The issues are backed by an sql table with json data for the various subtypes. There are various agents across the network which monitor/collect certain issues types.
Here is a sample of how I think issues should look like in crystal:
class DiskMissing < Issue
kind("hw_storage_disk_missing")
key(
slot : UInt32
)
tabelize do
column "Slot" do |row|
text row.key.slot.to_s
end
end
end
class LampEOL < Issue
kind("hw_projector_lamp_eol")
detail(
hours : UInt32,
hours_remain : Int32,
model : String,
serial : String,
)
tabelize do
column "Lifetime" do |row|
percent = row.detail.hours * 100.0 / row.details.hours_max
div {
div(style: "width: 100%;") {
div(style: "width:#{percent}%;") {
text "#{percent.to_i}%"
}
}
}
end
end
end
There is some macro magic involved:
the type registration (mapping db name to crystal class is done via the kind marco by adding this class to this Hash (our troublemaker) inside issue base class
there is a difference of key attributes and just ordinary attributes which is modeled with the key and detail macros.
an issue knows how to display itself …
To add new issue types to the system, a programmer shall just create a new file with such an issue class inside and done. (of course someone has to also implement the agent collecting this new issue).
The base class has a class method load which returns an array of all current issues (fetches the db table and serializes each row into the correct issue sub class or drops it if no suitable class registered).
Okay, so it seems to make sense that Issue should be abstract? This should be enough to resolve your issue. Otherwise if the JSON itself has a unique key when deserializing it you could look into JSON::Serializable - Crystal 1.13.1 as well.
And this hash is stored as a class var or something? How is it used? If it’s only used as part of the .load method to know all the issue types that need to be loaded, you could probably replace that with an annotation and {% for issue in Issue.subclasses %} macro to iterate over all the issues types, then access the table name off the annotation or give a compile time error if it’s missing.
I like the idea with iterating subclasses and use annotations!
Thank you very much!
But after all do I think, that the observed behaviour is a bug:
The JSON::Serializable subclass used as the class itself works fine. But when instantiated in a Type+.class context, it fails.
I believe the reason is that in the Type+.class context the macro generated constructor code doesnt see any instance_vars at all.
Unfortunately I lack the competence to dig deeper there. But maybe someone should have a look at it…
class Base
property name : String = "default"
def initialize(value : ::Int32)
{% @type %}
if true
@name = value.to_s
end
end
end
class Extended < Base
end
pp (Base || Extended).new(10)
In test.cr:4:7
4 | def initialize(value : ::Int32)
^---------
Error: instance variable '@name' of Base+ was not initialized in this 'initialize', rendering it nilable
abstract class Base
property name : String = "default"
def initialize(value : ::Int32)
@name = value.to_s
end
end
class Extended < Base
end
pp (Base || Extended).new(10)
You get a runtime compiler error?
Unhandled exception: Can't instantiate abstract class Base (Exception)
from /home/george/.cache/crystal/crystal-run-test.tmp in '??'
from test.cr:4:3 in 'new'
from test.cr:12:4 in '__crystal_main'
from /home/george/dev/git/crystal/src/crystal/main.cr:118:5 in 'main_user_code'
from /home/george/dev/git/crystal/src/crystal/main.cr:104:7 in 'main'
from /home/george/dev/git/crystal/src/crystal/main.cr:130:3 in 'main'
from /usr/lib/libc.so.6 in '??'
from /usr/lib/libc.so.6 in '__libc_start_main'
from /home/george/.cache/crystal/crystal-run-test.tmp in '_start'
from ???