Crystal: Abstract def class methods (e.g. constructors)

Created on 17 Apr 2018  路  11Comments  路  Source: crystal-lang/crystal

At the moment, there seems to be no way to abstract def class methods. This is a bit of a problem, in case you want to guarantee subclasses implement certain constructors! Take this for example:

abstract class Thing
  abstract def self.parse(s : String) : Thing
  abstract def self.construct_somehow(a : Int, b : String, c : Bool) : Thing
end

Right now, that doesn't work, but instead errors out with the error can't define abstract def on metaclass. The best way I can come up with to do something similar in the current version of Crystal is this:

abstract class Thing
  abstract def self.parse(s : String) : Thing
    raise NotImplementedError.new # Or similar
    new
  end

  abstract def self.construct_somehow(a : Int, b : String, c : Bool) : Thing
    raise NotImplementedError.new
    new
  end
end

...But that's runtime (and boilerplate-ey).

I hope there's something I'm missing!

feature lang

Most helpful comment

I think we could implement something like this, but if you misuse it you will still get a runtime error.

What I mean is, if you have:

abstract class Parent
  abstract def self.foo
end

class Foo < Parent
  def self.foo; 1; end
end

class Bar < Parent
  def self.foo; 2; end
end

If you do:

(Foo || Bar).foo

then the type of Foo || Bar, because they have Parent as a common type, is Parent.class+, that is, Parent or any of its subclasses. In this case the above code will work, but you could also have this:

(Parent || Foo || Bar).foo

In this case the type of the parenthesized expression is the same: Parent.class+. But the expressions' result is Parent and foo isn't defined for it (it's abstract), so I guess we can make it raise at runtime saying "foo is abstract for Parent".

This is still a bit better than the current state because it forces you to implement those abstract methods, and there's a very little chance that you'll end up misusing them.

Thoughts?

All 11 comments

Note that because of abstract class Thing, Thing is abstract so it can't be instantiated:

Thing.new # compile error

However, classes are already "instantiated":

Thing # => Thing

So, abstract methods in abstract types make sense, because they must be defined in concrete subclasses that can be instantiated. That doesn't apply to classes because you can use them without instantiating them.

So in my mind it doesn't make sense to have abstract class methods, and that's why they never got implemented. And in every other statically typed language there's no such thing either.

However, there is very much a legitimate use case for when you'd like subclasses to have the same set of constructors!

Sorry, I don't think this can (or will) be implemented.

FWIW there are other use cases for requiring children to implement a class method other than instantiation.

For example it would be nice if I could have did

abstract struct Athena::EventDispatcher::Listener
  abstract def self.subscribed_events : AED::SubscribedEvents

  ...
end

And in every other statically typed language there's no such thing either.

I know i'm slightly late, but there is such thing in Pascal\Delphi. https://ideone.com/qcrPSw
But they useful there because classes are "first-class ctitizens" in Pascal, so you can do
if random < 0.5 then c := Child1 else c := Child2; x := c.Create;

Rust's interface system, traits, can require implementors to provide static methods:

// abstract struct Foo
trait Foo {
    // abstract def self.bar
    fn bar();

    // abstract def baz
    fn baz(&self);
}

struct A { val: i32 }

// struct A< Foo
impl Foo for A {
    fn bar() {
        println!("bar");
    }

    fn baz(&self) {
        println!("baz: {}", self.val);
    }
}

fn main() {
    A::bar(); // print "bar"
    let a = A { val: 5 };
    a.baz(); // print "baz: 5"
}

This being missing is what prevents the concept of From being convenient in #8511 , which has a similar motivation to this issue

I think we could implement something like this, but if you misuse it you will still get a runtime error.

What I mean is, if you have:

abstract class Parent
  abstract def self.foo
end

class Foo < Parent
  def self.foo; 1; end
end

class Bar < Parent
  def self.foo; 2; end
end

If you do:

(Foo || Bar).foo

then the type of Foo || Bar, because they have Parent as a common type, is Parent.class+, that is, Parent or any of its subclasses. In this case the above code will work, but you could also have this:

(Parent || Foo || Bar).foo

In this case the type of the parenthesized expression is the same: Parent.class+. But the expressions' result is Parent and foo isn't defined for it (it's abstract), so I guess we can make it raise at runtime saying "foo is abstract for Parent".

This is still a bit better than the current state because it forces you to implement those abstract methods, and there's a very little chance that you'll end up misusing them.

Thoughts?

That sounds fine to me; at least, it makes sense from a user's perspective. I suppose this is somewhat consistent with other runtime type operations, such as

expr = "1" || 2
expr.as(Int32) # Unhandled exception: cast from String to Int32 failed

Although I'm curious about the compiler's understanding of that code; my impression is that the compiler isn't able to semantically block expressions like that containing abstract metaclasses, before they are cast to Parent.class+?
I can't quite imagine a use case that currently exists for handling Parent.class itself at runtime off the top of my head that would prevent making that a compiler error reasonable.

I see that (Parent || Foo || Bar).new.foo is, understandably, already a segfault: https://carc.in/#/r/85fj. This would presumably fix this as well?

Yes, that's already tracked here: https://github.com/crystal-lang/crystal/issues/3835

I once tried to fix it thinking it was easy but it's not. Maybe some day...

For reference, class methods are required on Serializable in the stdlib:

converter: specify an alternate type for parsing and generation. The converter must define from_json(JSON::PullParser) and to_json(value, JSON::Builder) as class methods.

In this case, a Converter module with abstract class method will be useful.

You can, in fact, do this already somehow:

abstract class Thing
  macro inherited
    extend ClassMethods(self)
  end

  private module ClassMethods(D)
    abstract def parse(s : String) : D
    abstract def construct_somehow(a : Int, b : String, c : Bool) : D
  end
end

class ThingImpl < Thing
  def self.parse(s : String) : ThingImpl
    # ...
  end

  def self.construct_somehow(a : Int, b : String, c : Bool) : ThingImpl
    # ...
  end
end

If .parse isn't defined you get abstract `def Thing::ClassMethods(D)#parse(s : String)` must be implemented by ThingImpl.class, or if it returns a Thing instead of ThingImpl, you get this method must return ThingImpl, which is the return type of the overridden method Thing::ClassMethods(D)#parse(s : String), or a subtype of it, not Thing.

This trick relies on the fact that T.f is mostly equivalent to T.class#f in Crystal; making Thing::ClassMethods private and not actually defining these methods in Thing.class itself means there should be fewer chances of misuse.

Likewise, for the converter case which does not require actual classes, one could write this:

module JSONConverter(T)
  abstract def from_json(pull : JSON::PullParser) : T
  abstract def to_json(value : T, json : JSON::Builder)
end

module Time::EpochConverter
  extend JSONConverter(Time)

  def self.from_json(pull : JSON::PullParser) : Time
    Time.unix(pull.read_int)
  end

  def self.to_json(value : Time, json : JSON::Builder)
    json.number(value.to_unix)
  end
end

{% Time::EpochConverter.class < JSONConverter(Time) %} # => true

No macro hooks are required here.

What I mean is, if you have:

abstract class Parent
  abstract def self.foo
end

class Foo < Parent
  def self.foo; 1; end
end

class Bar < Parent
  def self.foo; 2; end
end

[...] then the type of Foo || Bar, because they have Parent as a common type, is Parent.class+, that is, Parent or any of its subclasses.

I don't think Parent.class should be abstract; the mere fact you can pass Parent around means it is possible to create instances of Parent.class (even though the user doesn't get to call .new), so Parent.class is not an abstract base metaclass. If we interpret metaclasses as such and define abstract class methods in terms of the above snippets, (Foo || Bar).foo just works, whereas (Parent || Foo || Bar).foo should be a compile-time error, because the receiver's type is now Parent+.class instead of Parent.class+, and Parent.foo doesn't exist as Parent doesn't extend ClassMethods(Parent) itself.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

asterite picture asterite  路  70Comments

benoist picture benoist  路  59Comments

MakeNowJust picture MakeNowJust  路  64Comments

ezrast picture ezrast  路  84Comments

rdp picture rdp  路  112Comments