Proposal: Interfaces for more generic code

This was inspired by #8511, where I commented what I could see as a potential solution to the same problem. I figured I could outline it here and see what others from the community think about this idea.

The problem

Crystal has a pretty strict type system, which is awesome. Unfortunately, the stricter things are the harder it can be to write more generic code. For this reason modules are a godsend. Using a module you can pretty simply define a basic interface including a couple abstract types and include it in any class or struct you want. For example:

module Numeric
  abstract def to_i : Int32
end

The problem is that you then have to monkeypatch every type that fits this interface that you may want:

class String
  include Numeric
end

abstract struct Number
  include Numeric
end

# ...

The problem then becomes keeping track of every type that fits the interface, and requires monkeypatching which is generally an anti-pattern. This is also unfeasible, especially for use cases such as a logger.

The Solution

What I’m proposing is a new interface type to cover these use cases. It would work much like a module, but be completely abstract and not require you to implement it in each type you want to fit the interface. As an example:

interface Numeric
  abstract def to_i : Int32
end

class Foo
  def initialize(@bar : Numeric)
    # this should work
    puts @bar.to_i

    # this should either not work, or be dependent on the types being passed in.
    puts @bar.to_s
  end
end

The idea is to provide a generic interface for cases in which you only intend to use a limited subset of methods from a number of different types. Normally this would be handled using a messy alias type, or a module as described above. The problem is that the likelyhood of an alias/module covering all possible types is very low, and the alias/module becomes a maintenance burden.

Now if I want to instantiate the above Foo class, I can do this:

require "big"

foo1 = Foo.new(10000)
foo2 = Foo.new("100")
foo4 = Foo.new(3.14)
foo3 = Foo.new(BigInt.new("123456779984923492348234324234")
foo4 = Foo.new(MyCustomType.new) # Provided MyCustomType has a `to_i` method.

Obviously this example is contrived and there are workarounds for it. For example:

class Foo
  @bar : Int32

  def initialize(bar)
    @bar = bar.to_i
  end
end

But as I see it, even this has some issues.

First of all it’s not typed, and instead relies on the compiler to do almost exactly what I’m proposing and decided if the type being passed to Foo.new has the method #to_i. This means that documentation either has to be explicitly written to say what the type of bar is, or that type just won’t be documented.

This also may or may not put extra strain on the compiler. I don’t know, but I do feel like specifying ahead of time what methods the compiler should expect to find on a type should make things easier for it.

Conclusion

This is something I myself have needed many times, and I’m sure others have to. I don’t know that this is the right direction to go over what was proposed in #8511, but I do know that Crystal needs something like this. Strong typing in a language is already somewhat of a barrier to adoption, especially to our userbase which is mainly Ruby developers that are frustrated with how slow and error prone it is. Mostly though, I’m putting this here because @asterite has a tendency to make awesome PRs based on proposals like this and I’m really hoping he decides to make a PoC for this :wink:

7 Likes

This is a great idea, and I’d like this to happen.

The problem is that interfaces are typed, but in the rest of the language it’s not mandatory to type your methods.

So a user will create a type with def to_i without an explicit return type, and the compiler won’t consider that type.

For example BigDecimal has to_i without a return type. Same goes for BigFloat, Complex and maybe others.

The problem is that when you create an interface expecting to “capture” existing types with it, that might not happen because people might not put type annotations in methods. So you have to go tell them “hey, put type annotations there so they satifsy my interface”.

Instead, the current approach is to mark each of these types explicitly by including a module. It’s annoying, but it works. It also works like that in, for example, Haskell. There are no magical interfaces like in Go. Same goes for Rust, for Java, etc.

If we made all type annotations mandatory, then yes, sure. But without that “mandatory” aspect, I think interfaces can’t work.

This will also suffer the same problems we faced with abstract, like this one.

So I won’t create a POC for this, sorry :cry:

1 Like

Can’t the same thing happen that happens when duck typing? The compiler is able to determine the return value of a type at compile time, so is there any reason it couldn’t do the same with abstract as well as this potential interface type?

Only when the method is called.

Maybe it’s possible, but to do this it also needs to be done through virtual dispatch with virtual tables, and boxing might need to happen… it’s really hard to do this right now.

Maybe for 2.0?

2.0 would be fair, as long as it’s on the road map. I know a lot of the ground work is there, but I also know it will require a decent amount of work to make happen. It’s good to see the idea has the support of some core team members though.

1 Like

Crystal has interfaces in Java sense: