Annotate Inherited Variables

I’ve been working on a crystal project recently and this problem has really started to bite me. Why can’t we annotate inherited variables or can we annotate inherited variables while keeping the annotation relegated to the class that annotated it. My goal is to add some generic functionality in objects that is achieved through adding meta data to the class fields and later generating code through macros. In the following example

annotation SomeAnnotation
end

class NewException < Exception

  @[SomeAnnotation(metadata: "Something")]
  @message : String?
  
  def initialize(@message)
  end

  def some_function_that_uses_macros
     {% for var in @type.instance_vars.select(&.annotation(SomeAnnotation)) %}
     puts {{var.annotation(SomeAnnotation)[:metadata].stringify}} # not actual application but it's the jist
     {% end %}
  end
end

I try to add SomeAnnotation to @message in my NewException class but get the following error

Error: can't annotate @message in NewException because it was first defined in Exception

I’ve fiddled with it and came to this bit

annotation SomeAnnotation
end

class NewException < Exception

  class ::Exception
    @[SomeAnnotation(type: "NewException", metadata: "something")]
    @message : String?
  end
  
  def initialize(@message)
  end

  def some_function_that_uses_macros
    {% for var in @type.instance_vars.select{|var| var.annotation(SomeAnnotation) && var.annotation(SomeAnnotation)[:type] == @type.stringify}  %}
    puts {{var.annotation(SomeAnnotation)[:metadata].stringify}}
    {% end %}
  end
end

with this I have a unique identifier with which I can select the proper annotation for later use in a macro. but this seems pretty lousy, especially if I decide that I need to create another exception class that also depends on giving @message special metadata using the SomeAnnotation annotation. not to mention all these annotations would exist in the top level Exception class

Instance variables are owned by the type that declares them. All inheriting types share that original defintion. Considering that it makes sense to me that you cannot attach specific annotations per inheriting type.

I’d be interesting to hear more details about your specific use case. Could you elaborate what you want to do? Maybe there’s a different way to implement that?

Such a restriction may seem quite limiting when you are looking from a point of view where the functionality is presupposed. But technical limitations often have profund reasons. Forced to think about alternatives you might end up with a better solution than the original idea.

what I’m really trying to do is write a library for crystal that writes and reads objects to and from an IO with a standardized encoding/decoding protocol. There are a few pieces of information that’s required for writing to the standardized protocol. An ID number that is associated with a particular field i.e I would associate @message with an id of 1. An enumeration that represents the fields type (I don’t have an issue with this part). And I’d like to store some metadata that signifies a problem if a value is nil at the time of writing or reading. Annotations mostly work because the standard specifies that objects are standalone. Except for exceptions, which are supposed to be exceptions in the language that reads or writes an exception to the IO. heavy use of macros all around. There are a few things I’ve tried to circumvent this annotation issue but they all seemed excessive because an annotation would it all succinctly.

for example I’ve tried using constants that are just uppercase versions of the field name to associate a name with a field through some string manipulation, tried using hashes that define all the needed data. Currently there is a code generation part in c++ which is how I’m achieving this functionality currently. I’d like this project to be crystal as much as it possibly can for thorough native unit testing, I’d also like the code to look as close to native crystal code as possible so people can use it without too much macro magic obfuscating what’s happening.

I’ve been looking into appending the annotation to a getter which I think would be second best to appending the annotation to the variable.

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.

Oh that looks like it shouldn’t happen. Fixing this should make things easier I presume?

It would make it slightly less difficult, but I don’t feel like it would move the needle significantly — if I still have to annotate their instance variables, copy/pasting their annotations is trivial on top of that.

The main thing is that I don’t feel annotating instance variables in third-party code is good practice. Instance variables are a private implementation detail. I don’t think there’s a good way to work around that currently, to be clear, but if there is it would be so much more of a quality-of-life improvement for this use case than making annotations cumulative across definitions.

That’s not to say I think it should or shouldn’t be done.

it looks like appending an annotation to a getter method and using that in place of the instance variable is just about perfect. Add in a custom property macro and it’s easy enough to make it look like you’ve annotated a variable and a getter function has just about the same information. A big plus is I have access to the variable names and annotations in the top level of a class when invoking a finished macro.

my knee jerk reaction was that you should be able to re contextualize a variable with metadata through inheritance. Maybe that concept is more suited for interfaces rather than the is-a nature of inheritance.