flyx / NimYAML

YAML implementation for Nim
https://nimyaml.org
Other
191 stars 36 forks source link

Seemingly pointless proc #134

Closed Graveflo closed 1 year ago

Graveflo commented 1 year ago

I am experimenting with the Nim compiler and this proc in seriliazation.nim has me confused:

proc constructObjectDefault*(
    s: var YamlStream, c: ConstructionContext, result: var RootObj) =
  # specialization of generic proc for RootObj, doesn't do anything
  return

Its competing overload is O: object | tuple and the effect seems to be that the above matches for exactly RootObj() only (not to be confused with an object of RootObj). What is the purpose of this? I removed the proc to see what would happen and all of the tests still pass.

For full context regarding the nim compiler, I am looking into allowing generics and inheritance to actually compete with one another during overload resolution instead of generics always winning such that something like this:

type
  Foo = object of RootObj
  Bar = object of Foo

proc p(x:Foo):bool=false
proc p[T](x:T):bool=true
echo p(Bar())

Will print false instead of true

flyx commented 1 year ago

Good question. This was added in 576cf11 which added support for loading parent fields of a derived type.

For what I remember, I added it because the other constructObjectDefault failed when called directly on a RootObj. It was the more readable solution compared to introducing an edge case check in the other proc. This is an edge case but might be encountered via ref RootObj which can sometimes be useful, I suppose.

This doesn't seem to have made it into a test case, which is why removing the proc doesn't change anything.

Regarding your plan, I don't think it's a good idea because from my point of view, p[T](x:T) is obviously the more specialized candidate to call since it can be instantiated with T=Bar while the other proc uses Foo. Precedence of generics over inheritance feels intuitive while your suggestion doesn't.

If I needed to do something like this, I would try

proc p[T: Foo](x: T): bool = false
proc p[T](x:T): bool = true

but I don't think type classes can be used like this, at least they're not documented to do this.

Graveflo commented 1 year ago

Alright, as long as it was only meant to be an edge case for RootObj() exactly that is workable. I don't think that Nim is broken by its current behavior and by extension your rational makes sense. The instantiation of T is more specific, but the point of generics is to make abstractions as is inheritance. If you compare the signatures as abstractions Foo is far more specific than T since T can be anything. Also I don't think that moving a definition into the constraint is a logical way to deal with overload resolution. There isn't a reason for there to be a trade off in overload matching when you want type variable semantics. I've already made a PR to Nim about that too. An easy way to get on the track I am thinking without getting heady over the "correct" way to think about things is to consider the purpose of very general signatures like p[T: object](x: T). A proc like this is used for some basic behavior that works for all objects and in the context of overload resolution you are meant to make your overloads specialized by taking precedence over this "wide net" (or even more general, meant to be usurped in certain conditions). I don't see the rational of considering it's instantiation as it's specificity because then we are no longer talking about abstractions at that point. In this sense we are skipping over the mechanics purpose as pretending we are not talking about generics. You can just think of two competing generics if that makes it easier. They may both instantiate to the same concrete type yet one may be more specific than the other. Thanks for answering my original question. I'll go ahead and close the issue, but I am still interested to hear if you have anything else to say about overload resolution.

flyx commented 1 year ago

The instantiation of T is more specific, but the point of generics is to make abstractions as is inheritance. If you compare the signatures as abstractions Foo is far more specific than T since T can be anything.

Due to late binding of function calls in generics, p[T](x: T) actually can be more specific than p(x: Foo) when called on Bar():

type
  Foo = object of RootObj
  Bar = object of Foo

proc q(x: Foo): bool = false
proc q(x: Bar): bool = true

proc p(x: Foo): bool = q(x)
proc p[T](x: T): bool = q(x)

echo p(Bar())

Since q(x) in the generic proc is only resolved on instantiation, it calls the second, more specific, q. This scenario might look artificial but assume for example that q is $.


Generally, I would say generics and inheritance are two very different use cases.

Generics are a tool to implement an algorithm or data structure that has abstract requirements on some sort of payload. For example, a sorting algorithm needs indexable payload data with known length and comparable, copyable items, or a hashmap needs a hashable key with equality check. The requirements can be minimal and often are (e.g. a generic growable list only needs the payload items to be movable). The part about the requirements is often implicit because it is complex to codify in a language – just look at how long C++ needed to properly specify concepts.

Inheritance, on the other hand, is used for run-time polymorphism, like when you want to have different kinds of objects in the same container (e.g. for UIs with different widgets), or when you want a service to be able to consume different kinds of objects (e.g. most of what Java Enterprise does). Inheritance is also used for specialization, which is somewhat of a different use-case but from what I've seen usually comes up along run-time polymorphism.

The wide net analogy you mentioned describes specialization, so I'd say it describes inheritance but not generics. Generics are not about specialization, unless you're C++, then you use template specializations for type introspection because the language doesn't offer better compile-time capabilities. But Nim has them so it doesn't need this.


I did a quick test and was able to implement what you want to do with current Nim:

type
  Foo = object of RootObj
  Bar = object of Foo
  DerivedFromFoo = concept x
    x is Foo

proc p(x: DerivedFromFoo): bool = false
proc p[T](x: T): bool = true

echo p(Bar())

You need to write some more lines, but I'd say that this use-case is hardly common so there isn't much need for this to be short, and also it is obvious to the reader what happens. This is also rather close to how template specializations work in C++ – which might or might not be a good thing :).

Graveflo commented 1 year ago

I'm not sure what you are trying to say with the first example. When instantiated, T is bound to Bar, yes but this has nothing to do with overload resolution. If you look at the manual on overload resolution: https://nim-lang.org/docs/manual.html#overload-resolution you will see that it is the "formal parameter" that is compared in order to determine which candidate is selected, not the instantiated type. Instantiation happens after candidate selection and it must be this way or it doesn't make any sense. If the instantiated type was used for determining specificity then all matching generics should be ambiguous with one another and there would essentially be no specialization of generics at all. Generics and inheritance are very different, that is true, but they are both abstractions. Generics are symbolic abstractions that are evaluated at compile time and inheritance are data abstractions that support run time evaluation. Consider:

type A[T] = object

proc p[T](x: T):string = "T"
proc p(x: object):string = "object"
proc p(x: A):string = "A"
proc p[T](x: A[T]):string = "A[T]"
proc p(x: A[SomeInteger]):string = "A[SomeInteger]"

echo p(A[int]())

These are all generic procs with different levels of specialization. If you supplied something like A[int]() in this scenario every one of these procs would match for it, because they can all instantiate that type, but they are not ambiguous in this example for obvious reasons. The candidate is selected before instantiation by comparing the formal parameter type (T, object, A, A[T], A[SomeInteger]).


If what you said is true, in that inheritance is used for specialization and generics are not meant for it, then isn't this an argument for inheritance beating generics in overload resolution? It doesn't make sense for generics to swallow every specialized case of any object by simply having an object formal parameter if that is not what it is used for. I don't agree that generics aren't used for specialization but it's just a question to get you thinking about your rational.

Finally, the last example you provided is just using concepts (which are evaluated like generics) to hack in the logic that inheritance already implicitly has into the precedence level of generics. So yes, this is an example of what I am trying to do. When you take all of these things into consideration it is simpler then I think you are making it. You have candidates that define formal parameters and you have the types of the inputs. It's the job of overload resolution to pick the best matching candidate from the pool. Instantiation is a separate mechanism and specialization is used for both generics and inheritance. Right now inheritance is crippled by generics with the current rules and there is no reason for this from a design perspective.

flyx commented 1 year ago

but this has nothing to do with overload resolution.

My point was to show that it's generally wrong to assume a non-generic proc taking a parent type is a better match for overload resolution than a generic proc, which is to my understanding what you propose.

If what you said is true, in that inheritance is used for specialization and generics are not meant for it, then isn't this an argument for inheritance beating generics in overload resolution? It doesn't make sense for generics to swallow every specialized case of any object by simply having an object formal parameter if that is not what it is used for.

No, you're thinking about a generic function as if it were a non-generic function taking a RootObj parameter. My view is that it isn't, because generic instantiation can bind names in the generic body to specialized procs. And that's why my opinion is that the generic proc is rightfully beating inheritance.

Right now inheritance is crippled by generics with the current rules and there is no reason for this from a design perspective.

My experience is that I have never encountered this problem or, to my knowledge, worked around it without thinking much about it. I have a hard time envisioning use-cases for this that would show up somewhat regularly. So to sum up:

Then again, I'm not an active Nim user. I maintain this library because it's being used in the wild and that's it. I left Nim quite some time ago due to general disagreements with its design decisions so I'm certainly not someone whose opinion carries any weight and you're very welcome to disagree with me.

Graveflo commented 1 year ago

Yea that's alright. I like picking people's brain about this stuff because it matters to me. I'm not saying that inheritance should beat out generics, just that they should compete. The common use case or best fit example is the one from my initial post. If I did somehow get this change implemented I would PR a fix to this repo since it would be trivial. The code breakages are by far the greatest deterrent for this as far as I know. Thanks for entertaining my questions though