Agree about debugging. To my thinking, though, there are two types of "programming": professional (for day jobs, where debugging, testing, and maintainability matter), and recreational (where the point is to just explore new things and try crazy stuff that no one in their right mind would ever "really" do). This "def" stuff falls into the latter category.
Honestly, I wish there were more people doing posts about recreational programming topics. WhyTheLuckyStiff was one of the last great recreational Rubyists. I miss that kind of no-holds-barred exploration.
Debugging Ruby, in general, is often a pain in the ass. It stems from an awful combination of terseness and metaprogramming. The terseness comes from using an identifier to both represent variable reference and method invocation. Contrast this to Lisp-like langauges, where function (or macro) invocation only happens in the first position of an S-expression. In Lisp, it's clear that an identifier is either a variable or a function depending on where it's located.
In Ruby, no visual indication exists. Worse, things like attr_reader blend instance variables with local variables and method identifiers. Throw in a method_missing and inheritance, and you can easily lose weeks just tracking down where an identifier is even coming from. Throw in a gem or two, and all hope is lost.
> Contrast this to Lisp-like langauges, where function (or macro) invocation only happens in the first position of an S-expression. In Lisp, it's clear that an identifier is either a variable or a function depending on where it's located.
Both `foo' and `+' are variables here - while present on a first position of an S-expression at one point - even if you're running a Lisp-1 (like Scheme), i.e. where functions and variables share a namespace - or rather, a symbol can have separate function and value bindings. In Lisp-n (like Common Lisp) you can have a variable slot bound to a function value (i.e. lambda).
But one thing Lisp does have, which impacts readability significantly IMO, is simple and consistent syntax. Contrast to some Ruby-like languages which let you skip braces when working with dictionaries, making you stop and wonder how the hell a given piece of code is going to be parsed by the interpreter. Or Scala, which has so much context-dependent meaning bound to non-letter characters that I finally start to understand why people were afraid of C++ operator overloading. Both examples are, in my opinion, cases of syntactic sugar leading to cancer of semicolon.
The example you gave is clear, though. They are variables because they appear in a LET form. It's clear from the context what is going on. What I'm referring to, in Ruby, is something like:
def some_method
what_is_this
end
You don't know what what_is_this is. It could be an instance variable, a method (anywhere in the inheritance tower), or an autogenerated method from method_missing. It's impossible to tell without digging through the code. But the problem there is, you can't simply grep the code for things like this. You could end up with 100s of uses of the identifier and never find the source. Especially if you inherit a class from a gem, or method_missing has been used.
> In Lisp-n (like Common Lisp) you can have a variable slot bound to a function value (i.e. lambda).
Yes, and Lisp-1 vs Lisp-n has been a hot debate for decades in both Lisp and Scheme communities. I'm not about to claim it's a completely solved problem there.
> You don't know what what_is_this is. It could be an instance variable, a method (anywhere in the inheritance tower), or an autogenerated method from method_missing. It's impossible to tell without digging through the code.
We know it's not an instance variable -- that would be @what_is_this. It would have to be a local variable, but plainly there is no such variable local to this method. So we know it's a method. Let's go hunting (we could do this vis binding.pry, or byebug, or in IRB with an instance of whatever defined some_method):
method(:what_is_this) rescue false # if false, this is coming from method_missing
method(:what_is_this).source_location # there's your definition location, if it wasn't coming from method_missing
In general I used to find debugging ruby hard, coming from a background in more static languages. Once I learned the debugging facilities it provides, finding things got a lot easier.
True. You have to learn to treat programs written in dynamic languages as living, mutable, interactive things instead of designs set in stone. However, when a simple syntax derails your reading, it is a thing of concern.
Then again, you could make similar shenanigans in Common Lisp with `symbol-macrolet', but it's obscure feature that pretty much by definition will be used only by people who know when to use it :).
> However, when a simple syntax derails your reading, it is a thing of concern.
Definitely agreed! It's rare that I've come across a set of code that is _so_ over-abstracted or dynamic that reading the code doesn't usually shed light on the issue I'm after, and usually it's a "dense" language (Scala, Lisps, etc.) that manages to achieve that.
Last time I was really, seriously confused about the code was when going through a Scala codebase written by a person deep in love with traits. My problem was less of the syntax or "denseness" and more of nonlocality - every conceptually "whole" algorithm was split into 10 or 20 files and 2 class hierarchies, each having their own hierarchy of traits.
It made some sense in the end, but it took me a lot of time to figure that one out - not because it was complicated, but didn't fit in my head. That the original author was uncooperative and didn't want to explain things too much didn't help either.
> But the problem there is, you can't simply grep the code for things like this. You could end up with 100s of uses of the identifier and never find the source.
Personally, I see this as the entire point of the original Smalltalk-esque OOP paradigm: an object is a living, mutating black box that responds to messages, where an object's "type" is just "object": a thing with a protocol for sending and receiving arbitrary messages, not a thing with particular messages it is lexically known to respond to.
In other words, an object is like a remote server on the Internet. You can no more introspect an object by looking at the source of the classes it was originally constructed from, than you can introspect a remote web service by reading the source of the frameworks it was based on. The set of messages an object will respond to, and how it will respond to them, is part of the object's state, not part of its definition.
This "absolute encapsulation" forces the producer to publish an API if they want anyone to consume their object/service. This is great! If the API is machine-readable, and the object/service publishes it as a response to a message, an OOP runtime can even perform runtime introspection on the object/service, enabling consumers to configure themselves to speak another object's protocol, to reconfigure when that protocol changes, and even for two objects to negotiate a communications protocol among many options.
People have rediscovered the benefits of black-boxes with published, oft-machine-readable APIs in the last few years, calling the new version of OOP "microservices." It's the same idea: objects with no lexical type beyond "thing that responds to messages encoded in this format", being used as black-box interfaces into libraries which may be running locally or remotely.
This is also, effectively, the definition of a "process" in Erlang: something that will (asynchronously) receive messages if you send them, and might send you a response, or might not, with the interpretation of a given message depending entirely on the process's state. (People who say Erlang is a functional language are looking on the wrong level of abstraction. Processes are objects!)
---
To get back to Ruby, though: imagine an object which serves as a REST client, where the method_missing of that object translates the {method_name, ∗args} into a GET request to an API server, with the method name becoming the path and the arguments becoming query parameters. This is an idiomatic kind of Ruby object, because Ruby is actual-OOP rather than the "classes are types, right?" faux-OOP of static languages.
The messages this REST-client object responds to depend entirely on code running somewhere else that could be modified at any time. There is nothing static analysis tools could do to figure out what this object will or won't respond to. The only way to introspect its operation at all is at runtime.
And yet, I would argue that this implementation of such an object is the best, most 1:1 translation of the concept of "REST client" into a programming language. Errors are propagated from the remote server, through HTTP, into local dynamic-dispatch errors, through the defaults of Ruby's runtime. You're not having to go against the grain, writing a "get" method and then making up all sorts of custom exceptions it can raise. You just make a local object, that stands in for a remote object, and then you interact with it as an object.
> People who say Erlang is a functional language are looking on the wrong level of abstraction. Processes are objects!
People thinking those two are mutually exclusive are not getting the difference between the map and the territory :).
I worked a bit with Erlang professionally. Erlang is both functional and object-oriented, if you think about Smalltalk-style OOP and not Java-style OOP (the constant confusion between the concepts of those two is incidentally why I think that most things written about OOP are bullshit - they focus on wrong details; I've seen even university courses confusing the shit out of students by calling C++ methods 'message passing' and objects as equivalent to 'actors').
RE the problem of black boxes - black boxes are cool, what's not cool is if the interface you use to talk to black boxes is in itself confusing. In the example GP posted, it's not clear on first and second thought what exactly does the middle line mean - if it is a value, evaluates to a value, evaluates to a value with side effects, possibly taking your control flow for a sightseeing trip around the Moon, etc.
Also, in real world, we have to have some idea of what the black box is really doing. The only value that comes from assuming something is like "a remote web service" is knowing that it probably sucks, it's totally unreliable, if it responds at all it's after time noticeable to end user of your program, and you have to plan for it disappearing at any moment because the founder takes exit money or forgets to renew a domain. Yes, you could program treating everything like a web startup, but few simple assumptions like "this is inside my program so it responds fast and lives as long as the rest of the program does" can help tremendously improve the speed of coding and the speed of the program itself.
> Yes, you could program treating everything like a web startup, but few simple assumptions like "this is inside my program so it responds fast and lives as long as the rest of the program does" can help tremendously improve the speed of coding and the speed of the program itself.
I think the distinction between regular Smalltalk OOP (designed before distributed programming was a thing) and what people actually mean when they call a language "actor-modelled" is that, in a language like Erlang, you get to lean on the language itself (or in Erlang's case, the OTP framework) to be pessimistic about other objects' behaviors for you.
One thing that I think is missing from today's OOP language landscape, though, is a concept of protocol parameter adjustment for long-lived peers, ala TCP window scaling. While I wouldn't expect this of anyone's one-off RPC protocol, the frameworks like OTP, specifically made to cleverly handle OOP RPC stuff, should be capable of multiple levels of "formality" in how objects speak to one-another, where an object that e.g. repeatedly sends messages to a named service should be eventually JIT-optimized into one that grabs the PID of that named service and messages it directly. Of course, if that named service dies, the process will crash—but like any other JITed code, that's just the point at which the JIT abort-traps back into the un-optimized codepath and re-runs the function. This can be generalized to an arbitrary degree; you can go so far as to imagine e.g. distributed Erlang nodes that pass bytecode to one-another and gossip about code-changes, in order to be able to JIT-inline remote (pure) function calls.
A side point here is that it's incredibly hard to make people stick to abstractions when not sticking to them gives a competitive edge.
Say we've implemented your entire idea with JIT-optimized message passing. What would happen there is people learning ins and outs of particular JIT implementation and JIT-hacking being a required topic on job interview, just like today knowing millions of ways of hacking CPU cache is something expected from a professional (non-web) programmer (web programmers are expected to know there is something called 'processor' and that it doesn't like nested for loops; source - was a professional web programmer).
Anyway, while OTP does a pretty good job of papering over some of the local/remote objects difference and helps you keep the whole thing running even if something external breaks, I think it would be cool to go further in the direction of the ideas you just described.
I don't see anything hard to debug in this code. Honestly, if this kind of code makes debugging hard for one it means that one assumes too much about how code should behave instead of looking how it actually behaves.
I hate this "dynamic programming[0] = hard to debug" meme. A truly hard to debug code is one that's nonlocal (you have to read 20 files to follow the execution flow (hello Scala trait abusers)) or displays random behaviour (e.g. threading, dependence on external resources). This one? Every "tricky" thing is contained in a block of 10-20 lines. Just isolate the endpoints and follow the execution until it does something you think it shouldn't.
In a way, the biggest enemy of a bug hunter is their own assumptions.
[0] - examples here aren't really metaprogramming, and even the latter isn't that hard to debug if you actually sit down and read the code.
But still, this is a really cool trick that I didn't know you could pull off with Ruby, so thanks for sharing!