tk3369 / BinaryTraits.jl

Can do or not? It's easy. See https://tk3369.github.io/BinaryTraits.jl/dev/
MIT License
53 stars 3 forks source link

Function signature in @implement macro fixes meaning of first argument #29

Closed KlausC closed 4 years ago

KlausC commented 4 years ago

The type T which is assigned a trait determines the argument type of the first argument of the method specified in the @implement macro. That is a restriction with respect to the methods which can be requested to be implemented.

Wouldn't it be a better option to allow to specify the position of this argument?

For example the methods in Base, which need in iterator do not all expect the iterator as the first argument (map, reduce, get!, .........)

tk3369 commented 4 years ago

This is a great idea! I think doing this will solve issue #11 as well.

So we just need a new syntax. For example, if we adopt underscore to be the token for the object, then we can write:

@trait Mappable prefix Is,Not
@implement IsMappable by map(f, _)
KlausC commented 4 years ago

The underscore is a good syntax for that! We could also support multiple usage of the same type in the argument type list, e.g.:

@trait Multipliable prefix Is,Not
@implement IsMultipliable by *(_, _)
tk3369 commented 4 years ago

Setting this to high priority because

  1. it involves a breaking syntax change so we should do this sooner than later
  2. it's a very nice feature that makes the design consistent and easier to explain
tk3369 commented 4 years ago

It's a little unclear about how to support the case when there are multiple underscores. For example:

@trait PlayNice
@implement CanPlayNice by play(_, _)

struct Cat end
@assign Cat with CanPlayNice

struct Dog end
@assign Dog with CanPlayNice

Now, what makes Cat satisfy its contracts? At this point, there are 4 combinations:

play(::Cat, ::Cat)  # 1
play(::Cat, ::Dog)  # 2
play(::Dog, ::Cat)  # 3
play(::Dog, ::Dog)  # 4

I would think that case 1,2,3 should be checked because they all involve Cat when we call @check(Cat). But then we need to enumerate all possibilities. We only have Cat and Dog so far but what if we add more types to CanPlayNice? Then case 2 & 3 needs to be expanded to cover the new types. So we would have to call has_method many times for various combinations.

Alternatively, this is probably where Holy Traits pattern shines. Using the following code, the duck typed function would satisfy all possibilities. Is it cheating?

play(x, y) = play(playnicetrait(x), playnicetrait(y), x, y)
play(::CanPlayNice, ::CanPlayNice, x, y) = "great!"
play(::PlayNiceTrait, ::PlayNiceTrait, x, y) = "nah... let's go home"
KlausC commented 4 years ago

It's a little unclear about how to support the case when there are multiple underscores

Yes. I have the same impression. My thoughts about it.

  1. The @implement definition clearly defines a contract related to the CanType-trait, not to the "assignable" types.
  2. That means, in terms of your example, there must be an implementation for all cases 1-4. If there are m underscores and n assignable types with the same can-type-trait the amount will grow to to n^m potential implementations. It is improbable, that in general there will be this amount of methods implemented. It is also not checkable, because we do not know n; otherwise the contract need to be "closed" or the check results are volatile.
  3. How should the compliance of an assignee be checked then? IMO the decision to check for the existence of methods containing the "assignable" type alone (https://github.com/tk3369/BinaryTraits.jl/issues/11#issuecomment-615989008) was appropriate for m == 1, but not in general. I think now, that we should "dictate" the use of a certain kind of traits (we support only Holy-traits in the @traits macro, either). Additionally we have to establish a convention about the methods signatures when this kind of traits is used. For example, the traits objects must be the first arguments of the method as your example suggest. Or we introduce another symbol for the position of the traits-object, which gives more flexibility to the user. For the check macro, we can restrict to m cases, where all can-type-objects are verified and only one of the m positions must accept the assignable type, while the other m-1 positions are unrestricted (Base.Bottom). That means, if one of the positions accepts a Cat we don't care if the other is a Dog or a Mouse or something yet unknown, given they have the CanPlayNice trait.
  4. New syntax for the @implement macro using an additional symbol.

    @implement CanPlayNice by play(&, _, &, _)

where & indicates the position of the can-trait; number and order of & and corresponding _ must match.

  1. Checks for Cat would be done for play(::CanPlayNice, ::Cat, ::CanPlayNice, ::Base.Bottom) play(::CanPlayNice, ::Base.Bottom, ::CanPlayNice, ::Cat)

To understand me right, 1. - 5. are only my personal brainstorming about the initial question. It is not clear, if we should go into this direction. Finally it is your decision and it possibly it complicates the design too much. Just not support multiple place holders for single can-type would be an elegant alternative.

KlausC commented 4 years ago

Another idea: We let it as is currently implemented in #45 and document it properly. So the declaration

@implement CanXYZ by xyz(_, _)

is interpreted analogously to

function xyz(::T,::T) where T <:CanXYZ end

in plain Julia: both arguments must have identical type T, which is restricted by an abstract type. In your example, Cat plays nice with Cat and Dog plays nice with Dog, but Cat does not do with Dog. (From my experience with my own dog and our daughter's cat that is very realistic :-)

tk3369 commented 4 years ago

I like the option that requires zero effort ☝️ 😄

It may be good to see some adoption of this package before we make it too fancy. If there's a need, we will hear more about real use cases, and then we can discuss further and get more perspective.