rafaqz / Interfaces.jl

Macros to define and implement interfaces, to ensure they are checked and correct.
MIT License
72 stars 4 forks source link

Return types conforming to interfaces and other interface ideas #11

Open jkrumbiegel opened 1 year ago

jkrumbiegel commented 1 year ago

I think it's common enough in generic programming that one works with different layers of objects. For example some iterator of objects, where the iterator could be an array, a tuple, etc. and the object anything that serves some further purpose. That further purpose could again be locked down by an interface I thought. Here I jotted down some code where I made a ReturnInterface that should guarantee a given signature returns an object that conforms to a given interface (that interface can simply be that it should be a certain type, or whatever more complex thing).

I also had the idea of splitting run time and compile time traits and only allowing composition of compile time traits into new compile time traits, but I haven't followed that here. Just including it for inspiration. I bet that relying on _return_type has all sorts of problems, I already hit some uninferred cases during casual testing. But it seems like the only thing we got after all?

abstract type Interface end
abstract type CompileTimeInterface <: Interface end
abstract type RunTimeInterface <: Interface end
abstract type CompositeInterface <: Interface end

struct AndInterface{T<:Tuple} <: CompositeInterface
    interfaces::T
end

test_implements(x, a::AndInterface) = all(i -> test_implements(x, i), a.interfaces)

struct OrInterface{T<:Tuple} <: CompositeInterface
    interfaces::T
end

test_implements(x, a::AndInterface) = any(i -> test_implements(x, i), a.interfaces)

struct HasMethodInterface{SIG} <: CompileTimeInterface
    signature::SIG
end

test_implements(x, m::HasMethodInterface) = hasmethod(x, m.signature)

struct TypeInterface3{T<:Type} <: CompileTimeInterface
    type::T
end

test_implements(x, t::TypeInterface3) = x <: t.type

struct ReturnInterface{SIG,I<:Interface} <: CompileTimeInterface
    signature::SIG
    returninterface::I
end

function test_implements(x, r::ReturnInterface)
    ret = Base._return_type(x, r.signature)
    ret === Base.Bottom && return false
    test_implements(ret, r.returninterface)
end
rafaqz commented 1 year ago

Thanks these are interesting ideas!

Here I'm making types construct mock objects for testing, to get around _return_type like problems, but it does feel a bit clunky.

Your idea is interesting in that the tests are actually running at compile time. I'm not sure how far that will scale, previously I was running tests in precompile statements and got complaints even about that being too heavy, so removed it and rely on packages running interface tests in their test suite. For me that was a little disappointing in terms of how concrete and reliable the interface is.

Having the RunTimeInterface is a good workaround. We would still probably want to be able to run the tests for it separately to checking the trait so performance is decoupled from proof, was that the idea in your example?

The ability to compose And/Or interfaces is also cool. Especially Or, having a way to specify multiple lower level pathways that lead the same high level behavior seems pretty powerful.

I think this will at some point need the optional components as defined here, there is often just this one method in one of the interfaces that a specific type doesn't define.

stemann commented 1 year ago

Your idea is interesting in that the tests are actually running at compile time. I'm not sure how far that will scale, previously I was running tests in precompile statements and got complaints even about that being too heavy, so removed it and rely on packages running interface tests in their test suite. For me that was a little disappointing in terms of how concrete and reliable the interface is.

Having the RunTimeInterface is a good workaround. We would still probably want to be able to run the tests for it separately to checking the trait so performance is decoupled from proof, was that the idea in your example?

Interesting, indeed!

@rafaqz Are you suggesting that definitions of CompiletimeInterface and RuntimeInterface allow users (interface definitions) to opt-in to compile-time checks? That would be nice, as long as it would not be a barrier for actually defining interfaces.

jkrumbiegel commented 1 year ago

We would still probably want to be able to run the tests for it separately to checking the trait so performance is decoupled from proof, was that the idea in your example?

Yes that was the idea, that some things you're only ever going to be able to check at runtime. So they can be used for dynamic dispatch. But never for JET-style testing.

rafaqz commented 1 year ago

More like some interface checks might be too expensive to use compile-time checks and would need to work like they do currently in this package, with traits being separated from tests.

But if we actually were to do anything at compile time, we could just revert you last PR so that the mock object constructors are known at compile time, and go back to doing tests in precompile to avoid the overheads.

(Maybe Im not clear but a lot of my thinking and design here is around reducing the costs to a minimum so there is no performance or ttfx related resistance in the ecosystem)

stemann commented 1 year ago

More like some interface checks might be too expensive to use compile-time checks and would need to work like they do currently in this package, with traits being separated from tests.

But if we actually were to do anything at compile time, we could just revert you last PR so that the mock object constructors are known at compile time, and go back to doing tests in precompile to avoid the overheads.

(Maybe Im not clear but a lot of my thinking and design here is around reducing the costs to a minimum so there is no performance or ttfx related resistance in the ecosystem)

I think that was reasonably clear, but thanks for emphasizing your thinking - I completely agree that costs should be minimized.

One could also say, that one would expect the cost of both defining an interface and stating that an interface is implemented should be purely compile time and not a lot of it (close to zero).

JET-style static testing would also be an expectation (I have at least seen JET complain about a Duck not being able to dig, so I guess that already works? 😄).

Wrt. compile-time checks and compile-time test objects, I can see how that would need compile-time test objects for the predicate tests currently in this package, but I am wondering whether some tests could be done at compile-time even without test objects - e.g. just checking existence of methods and their signatures...

rafaqz commented 1 year ago

One could also say, that one would expect the cost of both defining an interface and stating that an interface is implemented should be purely compile time and not a lot of it (close to zero).

Totally, otherwise it needs to be done during testing.

Yes that was the idea, that some things you're only ever going to be able to check at runtime. So they can be used for dynamic dispatch. But never for JET-style testing.

@jkrumbiegel I'm not sure if I totally understand this... the interfaces defined in this pacakges currently are always used for compile time dispatch, rather than dynamic, its just that the testing of the trait is separated from the use of the trait to remove the runtime overheads. (with implements(Interface, Type) and test(Interface, Type, mocks)

Do you mean some interfaces can't be known at compile time at all?

jkrumbiegel commented 1 year ago

Well you had this in your readme which looked like runtime to me:

x -> age(x) >= 0,

That was the only reason why I included it, in principle I like compile time much more. Or to phrase it differently, type-based traits and not value-based traits.

rafaqz commented 1 year ago

Yeah I can see how it looks like that! But that runs in the tests (originally in precompile), not actually when you check a trait. The idea of putting it in the macro is to tie the trait directly to the test.

I will have to make all of these things clearer in the readme. See discussion in https://github.com/rafaqz/Interfaces.jl/issues/12

stemann commented 1 year ago

An idea might be to have separately specified type traits/predicates and value traits/predicates.

jkrumbiegel commented 1 year ago

That was the idea of CompileTimeInterface vs RunTimeInterface in the code snippet above :) I just didn't flesh out RunTimeInterface at all. Although I'm pretty sure one can go far without touching values at all. At some point you move from "interface" to just "code" that does something else based on values it encounters.

rafaqz commented 1 year ago

But does it make sense now that x -> age(x) >= 0 does not actually run at runtime?

One of the main questions with designing this thing is how to separate trait definitions from tests, and how object types that an interface is defined for are mapped to constructed objects that can be run in tests.

jkrumbiegel commented 1 year ago

But does it make sense now that x -> age(x) >= 0 does not actually run at runtime?

I have not understood that part yet, no. Do you mean you want to check instances of structs at compile time by creating and testing them inside the macro expansion step? So it's still value based, just run at a different time?

stemann commented 1 year ago

But does it make sense now that x -> age(x) >= 0 does not actually run at runtime?

Assuming "runtime" should have been "compile-time".

I would think there could be lots of type-traits and/or type-predicates that could be checked at compile-time - without incurring too much overhead, e.g. checking implements(interface_type, type) or something like test_implements(x, m::HasMethodInterface) = hasmethod(x, m.signature) in the first post.

Would that work?

stemann commented 1 year ago

That was the idea of CompileTimeInterface vs RunTimeInterface in the code snippet above :) I just didn't flesh out RunTimeInterface at all. Although I'm pretty sure one can go far without touching values at all. At some point you move from "interface" to just "code" that does something else based on values it encounters.

Excellent idea! 😄

Though it might be true that something is "interface" and something is "code", I do think that both type-traits and value-predicates have a place in an interface definition for Julia.

rafaqz commented 1 year ago

Do you mean you want to check instances of structs at compile time by creating and testing them inside the macro expansion step? So it's still value based, just run at a different time?

So how Ive done it here is that all of the checks actaully happen during testing, not when you define the Interface with the macro. The macro just makes the function that can run the tests.

So they can be either compile compile time or runtime checks, but it doesnt really effect (runtime) usage (of the traits) much either way.

So the traits you get are backed by tests that run at a different time. Maybe a bit weaker than you are going for. The reason was to reduce complaints about ttfx or runtime cost of using interfaces, so more a social/practical thing than the best design.

I really wanted to do the checks during precompilatiion.

rafaqz commented 1 year ago

Also FYI

https://github.com/Keno/InterfaceSpecs.jl

Keno doesn't seem to care about using return_type and similar things either

As far as I can tell it doesn't produce no-cost traits, it just the tests of the interface. But going for something a lot more advanced than I was.