crystal-lang / crystal

The Crystal Programming Language
https://crystal-lang.org
Apache License 2.0
19.32k stars 1.61k forks source link

Abstract def class methods (e.g. constructors) #5956

Open obskyr opened 6 years ago

obskyr commented 6 years ago

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!

asterite commented 6 years ago

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.

obskyr commented 6 years ago

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

asterite commented 6 years ago

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

Blacksmoke16 commented 4 years ago

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
konovod commented 4 years ago

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;

z64 commented 4 years ago

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

asterite commented 4 years ago

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?

z64 commented 4 years ago

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?

asterite commented 4 years ago

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...

j8r commented 4 years ago

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.

HertzDevil commented 3 years ago

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.

HertzDevil commented 2 years ago

The following would be a compilation error if #11190 is applied: (it does not matter whether Parent itself is abstract)

abstract class Parent
end

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

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

(Foo || Bar).foo # Error: undefined method 'foo' for Parent.class (compile-time type is Parent+.class)

Rethinking about this issue, I don't think we need to do anything special here; as long as Parent can possibly appear in a value of type Parent+.class, it is natural to require an explicit definition of Parent.foo. We could use a non-generic ClassMethods module if we really want to exclude Parent and enforce a distinct definition in the subclasses:

abstract class Parent
  module ClassMethods
    abstract def foo
  end

  macro inherited
    extend ClassMethods
  end
end

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

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

Now the following is an error:

(Foo || Bar).as(Parent::ClassMethods).foo # Error: can't cast Parent+.class to Parent::ClassMethods

But this works:

class A
  property! x : Parent::ClassMethods
end

a = A.new
a.x = Foo
a.x.foo # => 1
a.x = Bar
a.x.foo # => 2
a.x = Parent # Error: no overload matches 'A#x=' with type Parent.class

([Foo, Bar] of Parent::ClassMethods).map(&.foo) # => [1, 2]

IMO this is good enough and we don't need to adjust the semantics of abstract defs to allow this to happen.

they useful there because classes are "first-class ctitizens" in Pascal

To me it is the opposite; class reference types there do not reuse the same semantics as regular classes. (Contrast with Ruby's singleton classes.)