spy16 / sabre

Sabre is highly customisable, embeddable LISP engine for Go. :computer:
GNU General Public License v3.0
28 stars 5 forks source link

Member access for Go types from Sabre #12

Closed spy16 closed 4 years ago

spy16 commented 4 years ago

Sabre should be able to access member fields/methods of values bound in the scope.

Clojure uses (.MethodName target) or (.-FieldName target) for method and field accesses repsectively.

I am thinking this differentiation is not required in Go since Go types cannot have method+field of same name. ~But (.MemberName target) seems like a reasonable choice.~ In favor of keeping special handling to minimum, i am leaning towards exposing a special . function (. member-name target) which finds the member of the target with the given name and returns it,

Any thoughts on this ? @lthibault

lthibault commented 4 years ago

I've been thinking of this on my end as well, and agree that such functionality should be exposed.

I saw it as a (.method-or-field target) form, however, implemented by a ReaderMacro. I'm not opposed to the . function approach, but I'm curious why you prefer it?

In my mind, a Go-flavored lisp should aspire to the same minimalism that one finds in Go. One minor annoyance I have with various lisps (including Clojure) is the sheer number of exported symbols in the default namespace. Clojure's core namespace has over 200 exported functions, for instance! 🀒

One of my aspirations for my own project is to keep the language spec very small. In so doing, I hope to attain the same kind of simplicity as one finds in Go, and in order to achieve this, I would argue that we should build a lisp engine that reduces the number of functions that need to be implemented, rather than pushing functionality out to functions. A powerful introspection system for native Go structures seems like an opportunity to do just that.

I'm not sure what else we could be doing to move in this direction, but I'm interested in any thoughts you might have.

Returning to the point at hand, my gut tells me this should not be a function, but I'm open to being wrong! πŸ˜„

Addendum: regarding the point about Go-style simplicity above, I think this also touches upon an important marketing consideration for Slang. If we want this library to take off, it's important that we differentiate it from all the other "make a lisp" libraries on GitHub. A heavy emphasis on making simple (i.e.: small, feature-conservative) lisps is a very interesting niche in this respect. We would benefit from being unique as well as being aligned with the philosophy of the host language (Go).

spy16 commented 4 years ago

I have with various lisps (including Clojure) is the sheer number of exported symbols in the default namespace

Same here. That's why i took this decision in fact. My idea was that a minimal scope would export only few selected functions like ., def, fn* etc. and things like cons, conj, seq? etc. would be built using pure lisp and are not included by default.

I'm not opposed to the . function approach, but I'm curious why you prefer it?

To be honest, I am not totally happy with it either, i am definitely open for suggestions. Only reason I picked this was to ensure there is no special handling of things. For example if I picked (.member target) approach, everytime a list is evaluated, the evaluation logic would have to check if the first item in the list starts with a . ..

It's not possible to do this with a ReaderMacro in a generic way as it stands, and here's why: ReaderMacro is completely decoupled from a Scope, so it has no knowledge of what target values are available. Even if we introduce a new member access expression/form that reader emits, we will also have to provide a way to construct this expr without reader (lisp principle: code and data are not different)..

I can think of following alternatives here:

  1. Make Reader depend on a Scope and make it emit the member being accessed as the value from the reader.
  2. Enable (target.member args...) kind of access which would make it simpler (but need to handle ambiguity when a symbol itself has a . in it.
  3. Enable ((.member target) args...) approach

I think approach 2 would be the simplest to use in most cases.. But the problem is, we will not be able to use it against inlined values.. i.e., ((.Cons []) 1) can be done with approach 3 for example, but in approach 1 & 2 it would be ([].Cons 1) which is ambiguous for parsing..

A powerful introspection system for native Go structures seems like an opportunity to do just that.

This is what i was going for too.

I was thinking of simplicity in terms of design of Sabre as library itself: Provide a bare-bones environment which can be built upon.

lthibault commented 4 years ago

Enable target.member kind of access which would make it simpler, but we will lose capability to have nested namespaces like sabre.core.math (which i think is not necessary, let me know what you think)...

An alternative would be to take Python's approach and model namespace lookups as regular object lookups. Imagine you have a namespace with a foo.bar symbol that maps to a type t struct { Baz int }. The expression foo.bar.Baz could be supported by the following algorithm:

  1. Look for a common substring in the namespace. We find that the namespace contains foo.bar
  2. Get the corresponding object from the namespace. Call it o.
  3. Trim the common substring foo.bar off of the fully-qualified lookup symbol, foo.bar.Baz. The result is Baz.
  4. Repeat steps 1-3 using o instead of the namespace, until the the algorithm terminates.

Honestly, I really like this approach:

This is one of the things I think Python does extremely well. This kind of encapsulation is (IMHO) the only real upside to modeling data as objects. Provided we don't encourage stateful objects, I see no downsides.

This is what i am going for too. In fact now that member access is allowed, i was considering to implement things like cons, conj etc. through this.

+1 for this.

I was thinking of simplicity in terms of design of Sabre as library itself: Provide a bare-bones environment which can be built upon.

Yes, I think this is also very important. My suggestion was more of a supplement than an alternative. Point is: I'm favor of strategically adding a little bit of complexity in a select few places in order to encourage the design of simple languages.

spy16 commented 4 years ago

Enable (target.member args...) kind of access which would make it simpler (but need to handle ambiguity when a symbol itself has a . in it.

Actually, forgot to correct this part. This can be handled like you said and i really like this too.. It's how Go does it pkg.Symbol.Member.SubMember etc. it's how Python does it.. But one problem i see is w.r.t., member access on literal values.. for example, to access Cons method of a vector/list literals.. Like ([].Cons 1) which would be hard to parse.. This is not a major problem though if we think it's okay to add some additional boilerplate in case of literals.. i.e., (let [empty-vec []] (empty-vec.Cons 1))..

What do you think ?

lthibault commented 4 years ago

This can be handled like you said and i really like this too..

Awesome - then consider me onboard πŸ˜ƒ

But one problem i see is w.r.t., member access on literal values..

Just to make sure I understand, the difficulty is exclusively at the parsing level because it involves some form of lookback / context? For example, foo.bar is easy to parse because foo can be located in the namespace. However [].bar is hard because there is no [] symbol anywhere in the namespace.

Assuming I've understood the issue, I'm 100% onboard with your proposed mitigation strategy (which is basically to not worry it for now). It would nevertheless be good to handle this more elegantly in the mid-to-long term. Perhaps we can open an issue and get back to it later?

spy16 commented 4 years ago

However [].bar is hard because there is no [] symbol anywhere in the namespace.

Not quite. Issue is that the moment reader sees [ it drops into the logic that parses a vector and stays there until it reaches a matching ] and when it does, it's considered end of one form. Following . will not be considered as part of this. Instead another symbol .Cons will be emitted . Instead of having a form that expresses [].Cons you'll end up with 2 forms a list [] and a symbol .Cons ..

But I think it's best not to worry about this now at all. Gain in simplicity is bigger compared the loss in this minor detail.

lthibault commented 4 years ago

Ah yes, of course! Ok, makes sense.

But I think it's best not to worry about this now at all. Gain in simplicity is bigger compared the loss in this minor detail.

Totally agree.

spy16 commented 4 years ago

Cool. I'll try to get this done when I get sometime. Thanks for bringing this up.. I got caught up in doing things similar to Clojure I guess.. πŸ˜…

lthibault commented 4 years ago

Thanks for bringing this up.. I got caught up in doing things similar to Clojure I guess.. πŸ˜…

No sweat - I'm happy to be part of the design process :)

spy16 commented 4 years ago

Just to put the above discussion into final points:

  1. No special treatment for namespaces. They are just objects bound in the scope.
  2. Simply split the fully qualified symbol using . as the delimiter and do recursive lookups until termination.

And i think it would be nicer to allow kebab case naming for member access. (i.e., if the method of a type Foo in Go is named GetTheName, from Sabre it would be better to have foo.get-the-name kind of access)... I am considering may be both foo.GetTheName andd foo.get-the-name should work. Let me know what you think..

Also, I am thinking no dots allowed in symbols similar to all other languages. Clojure allows this but in a weird & useless way: (def hello.world 10) works and creates a binding, but when you try to use the symbol hello.world somewhere it just throws ClassNotFoundException.. I can't really think of a use-case where using . in names can make it better/readable. But allowing it can be confusing (For example when i type foo.bar.baz, it's not really clear if it is resolving baz of foo.bar or is foo.bar.baz itself is a value?)..

lthibault commented 4 years ago

πŸ‘ŒFor points 1 & 2.

And i think it would be nicer to allow kebab case naming for member access.

πŸ‘

I am considering may be both foo.GetTheName andd foo.get-the-name should work. Let me know what you think..

I'm very much in favor of this. Kebab-case is certainly convenient, but forcing case-conversion adds cognitive overhead for users, and may at times be more confusing than the native names.

Also, I am thinking no dots allowed in symbols similar to all other languages.

Strongly agree. Dots have special meaning. Disallowing their use in symbols helps signal intent. Furthermore, we should avoid letting people abuse dots in weird ways in case we need to make changes to field lookup semantics in the future.

Overall: πŸ‘

spy16 commented 4 years ago

This has been implemented. I have not done support for kebab case yet. For now going to keep only native names.