6 ms·
This comment seems unnecessarily mean-spirited... perhaps I just feel that way because I'm the person on the other end of it! I agree the code you have there i
by seeinglogic 2y ago
This comment seems unnecessarily mean-spirited... perhaps I just feel that way because I'm the person on the other end of it!
I agree the code you have there is very readable, but it's not really an example of what that sentence you quoted is referencing... However I didn't spell out exactly what I meant, so please allow me to clarify.
For me, roughly 5 calls in a chain is where things begin to become harder to read, which is the length of the example I used.
For the meaning of "multiple", I intended that to mean if there are nested chains or if the type being operated on changes, that can slow down the rate of reading for me.
Functional programming constructs can be very elegant, but it's possible to go overboard :)
- ninetyninenine 2y ago>For me, roughly 5 calls in a chain is where things begin to become harder to read, which is the length of the example I used. This isn't just about readability. Chaining or FP is structurally more sound. It is the more proper way to code from a architectural and structural pattern perspective. given an array of numbers 1. I want to add 5 to all numbers 2. I want to convert to string 3. I want to concat hello 4. I want to create a reduced comma seperated string 5. I want to capitalize all letters in the string. This is what a for loop would look like: // assume x is the array acc = "" for(var i = 0, i < x.length; x++) { value = x[i] + 5 value += 5 stringValue = str(value).concat(hello) acc += stringValue + "," } for (var i = 0, i < acc.length; i++) { acc[i] = capitalLetter(acc[i]) } FP: addFive(x) = [i + 5 for i in x] toString(x) = [str(i) for i in x] concatHello = [i + "hello" for i in x] reduceStrings(x) = reduce((i, acc) = acc + "," + i, x) capitalize(x) = ([capitalLetter(i) for i in x]).toString() You have 5 steps. With FP all 5 steps are reuseable. With Procedural it is not. Mind you that I know you're thinking about chaining. Chaining is eqivalent to inlining multiple operations together. So for example in that case x.map(...).map(...).map(...).reduce(...).map(...) //can be made into addFive(x) = x.map(...) toString(x)= x.map(...) ... By nature functional is modular so such syntax can easily be extracted into modules with each module given a name. The procedural code cannot do this. It is structurally unsound and tightly coupled. It's not about going overboard here. The FP simply needs to be formatted to be readable, but it is the MORE proper way to code to make your code modular general and decoupled.
- harrison_clarke 2y agoyou have this backwards: reusing code couples the code. copy+paste uncouples code if you have two functions, they're not coupled. you change one, the other stays as-is if you refactor it so that they both call a third function, they're now coupled. you can't change the part they have in common without either changing both, or uncoupling them by duplicating the code (you often want that coupling, if it lines up with the semantics)
- ninetyninenine 2y agoI meant code within the snippet is tightly coupled. You can't just cut the for loop in half and reuse half the logic.
- feoren 2y agoThe dig on chains of map/reduce/filter was listed as a "Halstead Complexity Takeaway", and seemed to come out of the blue, unjustified by any of the points made about Halstead complexity. In fact in your later funcA vs. funcB example, funcB would seem to have higher Halstead complexity due to its additional variables (depending on whether they count as additional "operands" or not). In general, long chains of functions seem like they'd have lower Halstead complexity. The "anti-functional Tourette's" comment was partly a response to how completely random and unjustified it seemed in that part of the article, and also that this feels like a very common gut reaction to functional programming from people who aren't really willing to give it a try. I'm not only arguing directly against you here, but that attitude at large. Your funcA vs. funcB example doesn't strike me as "functional" at all. No functions are even passed as arguments. That "fluent" style of long chains has been around in OO languages for a while, independent of functional programming (e.g. see d3.js*, which is definitely not the oldest). Sure, breaking long "fluent" chains up with intermediate variables can sometimes help readability. I just don't really get how any of this is the fault of functional programming. I think part of the reason funcB seems so much more readable is that neither function's name explains what it's trying to do, so you go from 0 useful names to 3. If the function was called "getNamesOfVisibleNeighbors" it'd already close the readability gap a lot. Of course if it were called that, it'd be more clear that it might be just trying to do too much at once. I view the "fluent" style as essentially embedding a DSL inside the host language. How readable it is depends a lot on how clear the DSL itself is. Your examples benefit from additional explanation partly because the DSL just seems rather inscrutable and idiosyncratic. Is it really clear what ".data()" is supposed to do? Sure, you can learn it, but you're learning an idiosyncrasy of that one library, not an agreed-upon language. And why do we need ".nodes()" after ".connected()"? What else can be connected to a node in a graph other than other nodes? Why do you need to repeat the word "node" in a string inside "graph.nodes()"? Why does a function with the plural "nodes" get assigned to a singular variable? As an example of how confusing this DSL is, you've claimed to find "visibleNames", but it looks to me like you've actually found the names of visible neighborNodes. It's not the names that are not(.hidden), it's the nodes, right? Consider this: function getVisibleNeighborNames(graph) { return graph .nodeByName(name) .connectedNodes() .filter(node => !node.isHidden) .map(node => node.name) } Note how much clearer ".filter(node => !node.isHidden)" is than ".not('.hidden')", and ".map(node => node.name)" versus ".data('name')". It's much harder to get confused about whether it's the node or the name that's hidden, etc. Getting the DSL right is really hard, which only increases the benefit of using things like "map" and "filter" which everyone immediately understands, and which have no extrinsic complexity at all. You could argue that it's somehow "invalid" to change the DSL, but my point is that if you're using the wrong tool for the job to begin with, then any further discussion of readability is in some sense moot. If you're doing a lot of logic on graphs, you should be dealing with a graph representation, not CSS classes and HTML attributes. Then the long chains are not an issue at all, because they read like a DSL in the actual domain you're working in. *Sidenote: I hate d3's standard style, for some of the same reasons you mention, but mainly because "fluent" chains should never be mutating their operand.
- atoav 2y agoTo me the functional style is much more easy to parse as well. Maybe the lesson is that familiarity can be highly subjective. I for example prefer a well chosen one-liner list comprehension in python over a loop with temporary variables and nested if statements most of the time. That is because usually people who use the list comprehension do not program it with side effects, so I know this block of code, once understood stands for itself. The same is true for the builder style code. I just need to know what each step does and I know what comes out in the end. I even know that the object that was set up might become relevant later. With the traditional imperative style that introduces intermediate variables I might infer that those are just temporary, but I can't be sure until I read on, keeping those variables in my head. Leaving me in the end with many more possible ways future code could play out. The intermediate variables have the benefit of clarifying steps, but you can have that with a builder pattern too if the interface is well-chosen (or if you add comments). This is why in an imperative style variables that are never used should be marked (e.g. one convention is a underscore-prefix like _temporaryvalue — a language like Rust would even enforce this via compiler). But guess what: to a person unfamilar with that convention this increases mental complexity ("What is that weird name?"), while it factually should reduce it ("I don't have to keep that variable in the brain head as it won't matter in the future"). In the end many things boil down to familiarity. For example in electronics many people prefer to write a 4.7 kΩ as 4k7 instead, as it prevents you from accidentally overlooking the decimal point and making an accidental off-by-a-magnitude-error. This was particularly important in the golden age of the photocopier as you can imagine. However show that to a beginner and they will wonder what that is supposed to mean. Familiarity is subjective and every expert was once a beginner coming from a different world, where different things are familiar. Something being familiar to a beginner (or someone who learned a particular way of doing X) is valuable, but it is not necessarily an objective measure of how well suited that representation is for a particular task.
- jghn 2y ago> Maybe the lesson is that familiarity can be highly subjective. Over the years I've come to firmly believe that readability is highly subjective. And familiarity is a key contributor to that, but not the only one. There are other factors that I've found highly correlate with various personality traits and other preferences. In other words, people shouldn't make claims that one pattern is objectively more readable than another. Ever. I've reached the point where anyone who claims some style is "more readable" without adding "to me" I just start to tune out. There's very little objective truth to be had here. What should one do? If on a team and you're the outlier, suck it up and conform. If you're on a team and someone else is the outlier? Try to convince them to suck it up and conform. If you're on a new team? Work empathetically with your teammates to understand what the happy medium style should be.
- p2edwards 2y agoAgreed — seeinglogic's article made me think of a 3rd option: 1. Sorta long functional chain where the type changes partway through 2. Use temp variables 3. (New option) Use comments (Here's funcA from seeinglogic's article, but I added 3 comments) function funcC(graph) { return // target node graph.nodes(`node[name = ${name}]`) // neighbor nodes .connected() .nodes() // visible names .not('.hidden') .data('name'); } Compare to funcB which uses temp variables: function funcB(graph) { const targetNode = graph.nodes(`node[name = ${name}]`) const neighborNodes = targetNode.connected().nodes(); const visibleNames = neighborNodes.not('.hidden').data('name') return visibleNames; } For me the commented version is easier to read and audit and it also feels safer for some reason, but I'm not how subjective that is.
- daotoad 2y agoFunny, I see the need to add comments as an indicator that it's time to introduce chunking of some sort--arguably it already has been. In this case, I'd lean towards intermediate variables, but sometimes I'll use functions or methods to group things. I prefer function/method/variable names over comments because the are actual first class parts of the code. In my experience, people are a bit more likely to update them when they stop being true. YMMV.