Inheritance isn’t the only use case this impacts. My use cases don’t involve inheritance, but it does involve adding serialization formats to types provided by a shard in addition to any formats originally provided by the shard.
- I want to be able to cache objects that come from third-party API clients in an effort to improve latency, mitgate rate-limiting from that API, or both. The API client shard includes
JSON::Serializable
onto its data objects but to cache them I have to also include MessagePack::Serializable
.
- An shard providing authentication defines a
User
type that includes DB::Serializable
. I want to be able to also serialize the User
type to serialize that user into JSON over my API.
- I want to be able to serialize a type provided by a shard that doesn’t provide any serialization at all.
In all cases, I want to be able to simply reopen the class, include the other Serializable
mixin, and, if necessary, customize the MessagePack serialization for specific properties. Everything about the *::Serializable
convention in Crystal is buttery smooth except for that kind of customization, when it isn’t part of the original definition. And since it’s the only part of serialization in Crystal that’s at all clunky, its clunkiness stands out pretty intensely.
annotation SerializableWithJSON
end
annotation SerializableWithMsgPack
end
####### Library implementation of the object here
struct Foo
@[SerializableWithJSON]
getter bar : String = ""
end
####### My application code here
struct Foo
@[SerializableWithMsgPack]
@bar : String
end
######## This definition for the type exists only because I need a method context to inspect `instance_vars`
struct Foo
def lol
{{ @type.instance_vars.first.annotations.map(&.stringify) }}
# => ["@[SerializableWithMsgPack]"]
end
end
pp Foo.new.lol
Note how adding the SerializableWithMsgPack
annotation replaces the SerializableWithJSON
annotation. So I have to repeat any previous annotations to the instance variable.
Then, since annotations are attached to instance variables rather than methods, you have to modify the implementation rather than the interface. If there is a change to how that instance variable is defined, my code has to adapt even if the interface as a consumer of that shard does not change. For example, consider the following diff:
- getter items : Array(String) = [] of String
+ getter items : Array(String) { [] of String }
In this case, the type of the instance variable changes without changing the public interface to it. In an ideal world, as a user of this shard, my application code shouldn’t have to change. The only reason it does is that annotations are attached to the implementation, rather than the interface.
I’m not sure what a solution to this could looks like or I would’ve opened an issue about it years ago, but it’s something I deal with regularly. Every solution I’ve tried is infeasible in Crystal because of other macro limitations. For example, delegating serialization and deserialization to instance methods:
mixin code
require "json"
module Serialization
module JSON
macro included
include Serialization
def self.from_json(source : String | IO) : self
from_json ::JSON::PullParser.new(source)
end
private def self.from_json(pull : ::JSON::PullParser)
instance = allocate
instance.initialize(json: pull)
GC.add_finalizer(instance) if instance.responds_to?(:finalize)
instance
end
end
def initialize(*, json : ::JSON::PullParser)
{% begin %}
{% for ivar in @type.instance_vars %}
%found{ivar.name} = false
%value{ivar.name} = nil
{% end %}
json.read_object do |key|
{% begin %}
case key
{% for ivar in @type.instance_vars %}
when {{ivar.name.stringify}}
%found{ivar.name} = true
if deserialize_{{ivar}}?(json)
%value{ivar.name} = deserialize_{{ivar}}(json)
end
{% end %}
end
{% end %}
end
{% for ivar in @type.instance_vars %}
if %found{ivar.name} && !%value{ivar.name}.nil?
@{{ivar}} = %value{ivar.name}
else
{% unless ivar.type.nilable? %}
raise "Expected {{ivar}} to be {{ivar.type}}, but it was either not in the JSON payload or it was null"
{% end %}
end
{% end %}
{% end %}
end
def to_json(io : IO) : Nil
json = ::JSON::Builder.new(io)
json.start_document
to_json json
json.end_document
end
def to_json(json : ::JSON::Builder) : Nil
json.object do
{% verbatim do %}
{% for ivar in @type.instance_vars %}
json.field {{ivar.name.stringify}} do
if serialize_{{ivar}}?(json)
serialize_{{ivar}} json
end
end
{% end %}
{% end %}
end
end
end
end
require "serialization/json"
struct UserJSON
include Serialization::JSON
getter id : Int64
getter name : String
getter created_at : Time
def initialize(*, @id = rand(Int64), @name, @created_at = Time.utc)
end
## DESERIALIZATION
def deserialize_id?(json : ::JSON::PullParser)
!json.kind.null?
end
def deserialize_id(json : ::JSON::PullParser)
Int64.new json
end
def deserialize_name?(json : ::JSON::PullParser)
!json.kind.null?
end
def deserialize_name(json : ::JSON::PullParser)
String.new json
end
def deserialize_created_at?(json : ::JSON::PullParser)
!json.kind.null?
end
def deserialize_created_at(json : ::JSON::PullParser)
Time.new json
end
## SERIALIZATION
def serialize_id?(json : ::JSON::Builder)
true
end
def serialize_id(json : ::JSON::Builder)
json.number id
end
def serialize_name?(json : ::JSON::Builder)
true
end
def serialize_name(json : ::JSON::Builder)
json.string name
end
def serialize_created_at?(json : ::JSON::Builder)
true
end
def serialize_created_at(json : ::JSON::Builder)
json.string do |io|
created_at.to_rfc3339(fraction_digits: 9, io: io)
end
end
end
Ideally, those serialize_*
and deserialize_*
methods would be generated by including the mixin and individually overridable, but that isn’t possible since macros don’t have access to the instance variables in a context that can define methods.