I think this most effectively demonstrates why I like a lot of OOP: it can be verbose. This is example function is relatively illegible:
alignCenter :: [String] -> [String]
alignCenter xs = map (\x -> replicate (div (n - length x) 2) ' ' ++ x) xs
where n = maximum (map length xs)
One of the most verbose languages I've used, Objective C, has made this a best practice. Despite the brackets (which scare people off), it can be some of the easiest to read code.
I think this is a question that's orthogonal to OOP-versus-functional-programming, but is rather about programming style. Haskell programmers like being terse, but there's also nothing stopping you from writing Haskell like this:
alignCenter :: [String] -> [String]
alignCenter lines =
let maxLineLength = maximum (map length lines) in
[ leftPadding ++ line
| line <- lines
, let leftoverSpace = maxLineLength - length line
leftPadding = replicate (leftoverSpace `div` 2) ' '
]
You could have verbose and easy-to-read functional code—you don't see this often in Haskell by convention, but you might in OCaml or in Scheme—and you could also have terse OOP code filled with single-letter variable names (less so in Objective C by virtue of the method call syntax, but I've definitely seen code like this in Java and JavaScript.)
Functional programming seems to let people lapse into "point-free style" fairly easily, which (imho) has a much greater chance of becoming rapidly unreadable. Not to say FP can't be readable as you've demonstrated very well.
Point free can totally be abused. It's also, in my opinion, often very useful for clarity.
It's my experience that, especially with FP code, often the important things to write down are the stages/steps in a transformation and that the intermediate outputs do not, by themselves, hold much semantic meaning.
Pointfree lets you write just those steps. Moving to "point-full" coding forces you to give names to these semantically meaningless intermediates. This can sometimes just be noise.
Terseness is also just shocking sometimes. It can take time to get used to it. I feel like when I read more verbose languages I skim each block of code numerous times and collect the meaning iteratively. When I read Haskell, I depend upon each name having distinct and important semantic meaning, I leverage type information heavily, and when I do read something I read each word carefully.
Totally different styles, but I've found strong preference for terseness.
This may be more common with Haskell specifically, because Haskell makes currying so easy and has tools like http://pointfree.io/ (which I’ve definitely abused before).
Point free style possible in languages like Haskell and SML where the syntax makes it practical. Haskell and SML make it easy, Scala does not. If you made an OOP language with the right syntax features, you could have point-free style in that language too. You can do point-free style in Python with just a little effort. It's orthogonal to the FP/OOP debate.
Maybe true, but writing readable code is a difficult thing in any language. No matter what language you're using, you're going to have to come up with a set of coding standards in the long run.
Honestly...how is that more readable? It's more verbose sure but the first example is much more readable and straightforward. Here, you have to remember Haskell's list comprehension syntax and you have to scan up and down a few times to keep track of the variables.
> …you have to remember Haskell's list comprehension syntax…
I get the point you're trying to make, but this feels like a huge stretch. Haskell's list comprehension syntax is straightforward and in many cases a terse and useful way of implementing complex functionality. You'll find multiple uses of it in the base libraries; notably, catMaybes is implemented in terms of list comprehension:
catMaybes :: [Maybe a] -> [a]
catMaybes ls = [x | Just x <- ls]
I'm all for reducing cognitive load, but Haskell has such a sparse syntax and this specific feature is so useful (and honestly, so consistent with the rest of Haskell's syntax) that arguing it's some kind of unnecessary cognitive burden feels ridiculous to me.
Yeah I mostly agree but I mentioned it because I spent some time trying to figure how how the leftPadding variable in the let block can be referenced outside the let block. It's been a couple of years since I've used Haskell.
The main benefit of example 2 IMO is that some variables got readable names. E.g. n became maxLineLength and x became line.
But bad naming practices have nothing to do with FP per se. Although it does seem to be fashionable in part of the FP community to have everything as terse as possible including variable names.
To me, code with terse variables are much more readable since it's much easier to keep track of the variables when scanning the code. When I read that piece of code above, I didn't try to ascertain meaning from the variable names which, it's arguable whether I (or anyone) should or not since coders can assign variables arbitrary names.
I honestly think a good amount of legibility is a matter of familiarity. I've used Haskell for a couple of years, and I didn't find this code particularly illegible (if I had to guess, this is probably more easily-understandable to me than the equivalent OOP formulation).
That's not to dismiss your criticism, not at all; rather, I think that the challenge would decrease with experience.
To underscore the point that legibility is as much a matter of familiarity as anything else: I think a big difference with an "OOP language" like the above poster seems to prefer versus Haskell has a lot more to do with the English language keyword operators that most contemporary OOP languages favor versus Haskell favoring more mathematical notation.
There exist "OOP Languages" like OG Smalltalk, Self, Io that might make for better comparisons to Haskell by virtue of being closer to the simpler syntax and general focus on more "mathematical" operators. Just as there exist more functional languages that use much more of an English keyword approach than Haskell.
(Some forms of Lisp/Scheme are so English keyword-forward with micro-DSLs that they may be a better example in a lot of posts like these for comparing "contempory OOP languages" and "functional languages". Plus there's all the bits of hybridized "functional languages" embedded directly inside contemporary "OOP" languages such as LINQ syntax in C#. It's interesting to me how many of these sorts of articles jump straight to Haskell.)
var input = new string[] { "abc", "ab", "abcdef", "abcdefgh" };
var maxLength = input.Select(e => e.Length).Max();
var output = input.Select(e =>
{
var padding = (maxLength - e.Length) / 2;
return new String(' ', padding) + e;
});
Most modern `oop` languages these days all support functional constructs and achieve the same thing in same amount of code & style. Language and oop/functional style are not mutually exclusive anymore, which this article seems to overlook by comparing both languages and styles at the same time.
I tend to avoid doing a Select followed straight by a Max when you can just pass the select function directly into the max. Also multiline lambda expressions feel ugly but that's probably just me! This is what I would do these days:
string pad(this string e, int padLength) => new String(' ', padLength) + e;
var input = new string[] { "abc", "ab", "abcdef", "abcdefgh" };
var maxLength = input.Max(e => e.Length);
var output = input.Select(e => e.pad((maxLength - e.Length) / 2));
It would be interesting if you could mention which feature/s you think it most sorely lacks. Some of the recent syntax additions have made it really easy to write concise functional code. I don't even use curly braces much anymore. What about f#?
I think that's preposterous and totally unfounded.
The identifiers have names like "alignCenter", "replicate", "maximum" and "length". Those are all actual full English words, nothing remotely obfuscated about them.
"map", "++" for concatenation, and "x/xs" for an arbitrary list of things are all idiomatic so can be kept short because they are used so often, like pronouns in English.
Lastly, your program looks very similar (almost isomorphic) to the Haskell version, so I think it's just dumb you use it as an argument to criticize the Haskell version as "extremely terse".
My problem with functional programming written like this (I admit that this example is too short) is that once there are too many functions combining with other functions it is very easy to lose thread of what is happening. My comparison with perl is more to the point that these programs are way easier to write than read.
Again, this example is too simplistic to illustrate my point, but what I like with combining imperative and functional programming is that it is easier to keep trace of what is happening by using intermediate variables that hold the state and thus break the chain of functions.
That is why the importance of being terse but following the rule of not going too far with nesting and pointfree is so valuable. I don't share your difficulty to follow the flow of the program as usually the name of the function, type, and documentation say all you need to know about it.
That said, I find it difficult to follow what is happening when using several variables as you're then likely doing something wrong in the complexity of the function. A function should say much with very little and control flow should be clear instantly. The only thing outside of this would be a procedure which do-notation allows one to express.
Imperative programming is already within Haskell with do-notation an it's composition is that of imperative. Mixing functional with something else makes it much harder to make use of combinators and other forms which ends in more messy code (in my experience). If one could go pure functional without do-notation emulating the "C monoid" it would be rather nice.
It does look like APL or Perl without enough understanding of Haskell (which I know very little about). I am guessing you have some experience writing Haskell code, so it looks clear to you.
The example used idiomatic one or two letter variable names (x, n, xs) which I found difficult to read. The rewritten example was much clearer to me because I could see the variables.
This opinion is flameworthy and stereotypes heavily: what seems to happen in FP is that the overall community is substantially math-IQ smarter than the imperative languages. Alas, that ALSO means the overall community loses social-IQ in the process.
This leads to:
1) higher barrier to entry for the general programmers, in language semantics, documentation, examples...
2) a tendency to overabstract, underdocument, and write clever code
3) write code that is obvious to the person who wrote it (at least for a few months), but being CODE is tougher for people to unpack.
4) people don't collaborate in packages, so they tend to be single-hero projects that are abandoned, and since FP code is a bit harder to parse for typical programmers, don't get adopted/unorphaned. Thus the library code beyond the standard library gradually degrades.
5) I won't say that FP is the only domain of religious zealotry in language minutae (syntax, etc), but it does seem to have a higher proportion or much louder zealots.
I've been in the industry for 30 years, and while it could just be mental ossification of age, nothing seems to have changed since the usenet days with Scheme/LISP, despite the fact the toolchains are now free and downloadable, and despite some of the smartest people in the world preferring them.
The fact that Rust is gaining some momentum with its fairly alien memory management is yet another example to me that the IQ barrier of purer FP (no infix, lots of recursion, etc) is just too high to surmount.
> 3) write code that is obvious to the person who wrote it (at least for a few months), but being CODE is tougher for people to unpack.
One could have a decent debate about much of what you say. This is the only one where I utterly object. The Haskell code I write is far easier to come back to months later than the Python code I write.
That's kind of another aspect of the social-IQ divide.
Programming is very democratic and blue collar as white collar jobs go: you can get stuff done without a lot of formal education (OR the formal certification of (ahem) REAL engineering professions).
So either you ivory tower and sniff at the lower classes and use FP and higher tools, or you "get stuff done" actually making tools (like the dude who make the OSX package management getting stiffarmed by google).
The old CS vs no CS divide actually fissures quite dramatically in alignment with FP vs no FP.
I suck at more advanced math. I am simply not able to grasp it. However I am very good at logic and systems thinking, which makes me a good programmer for certain kind of tasks, provided I can use imperative programming. Force me to use functional programming (like my current Angular/RXJs assignment) and I'm a lousy programmer.
As a lisp programmer who never wrote in Haskell the snippet you quoted is crystal clear to me, while with explicit loops I always have to double check, because I am pattern matching the code "ah, a map or reduce was meant" but I still have to double check assignments and the abort condition (off by one, incrementing the wrong index etc).
I think this is definitely a problem with a lot of functional code styles so when working functionally myself I try to be quite verbose and explicit within functions, spacing things out in recognizable patterns and using lets to capture intermediate computation steps.
I love side-effectless code, but I hate the assumption that side-effectless code is maximally perfect when on a single line - don't use variables for variable usages, use labeled pre-computations to clarify what you're actually doing to try and maintain readable self-documenting code.
(all that said, the example above is... sort of a weirdly terrible one like most string manipulation tasks are, string manipulation usually has a high ratio of computation to significant design decision making)
I'm finding that in Javascript, I really enjoy map() and filter(), but map() caused some friction on code reviews at one place, from someone I don't think had any FP background. The are best when they are one liners, of the form .map((val) => someFunction(val)) or .filter((val) => val.length > 1)
reduce() on the other hand is always illegible to me. I get pretty grumpy when I am 'forced' to use a reduce because the code calls for it. It just looks like I'm trying to write obfuscated code.
Often I'll just unroll it to a forEach.
This code structure in your example reminds me of some Python code I was reading recently (Norvig's Sudoku solver). Putting the conditional at the end of the line fights the way humans process data. Quite often we are trying to filter lines of code that could be involved in a problem and saying things like "do an action (if this condition is true)" increases the amount of short term memory I have to use to process the function. That's bad ergonomics.
"but map() caused some friction on code reviews at one place"
Someone not familiar or comfortable with "map" should not be working as a professional programmer today. Most languages now support "map" or something very similar.
Try to move to a different team or different company with actual professional developers.
> Someone not familiar or comfortable with "map" should not be working as a professional programmer today.
I completely disagree - you are measuring the quality of a programmer on a single dimension.
I have worked with very weak programmers that produce gold e.g. great at pulling a team together, great at producing outcomes that clients love, great at focusing on features that sell.
I have also worked with great programmers that just stick to what they know - they don't know map() because they concentrate on being productive rather than continually chasing the next greatest language or library.
I have also worked with technically awesome programmers that produce absolute crap e.g. struggle to communicate, struggle to make good engineering compromises, struggle to understand requirements. One smart guy was so creepy no women could work with him which meant he was actually pretty useless - I saw one friend who worked as a consultant there hide under her desk to avoid him!
I almost fully agree. A professional programmer, how can such a person not know about stuff like map, filter and reduce? It can only happen, when they never cared to learn programming languages, paradigms or concepts of various kinds. This would betray an attitude of not continuing to learn. Even, if they have not come into contact with other programming concepts, how can any professional not have heard anything about map-reduce stuff at some point somewhere? Seems very unlikely to happen without learning-resistance or disinterest towards learning or informing oneself.
However, I would exclude junior software developers from this, as they might have just come from university, where they might not have learned about this at all, depending on the university. Still computer programming is then what they do as "profession", so we would have to count them as "professionals".
That was more of a "it's not universal" comment. Sometimes the right solution is self obvious, other times it needs PR.
If I write a piece of code so others don't have to, then it's a service I'm providing. If they don't 'get' the code then the problem is not always with them. It's important to watch for patterns in the questions or complaints you get. There are often multiple acceptable ways to solve the same problem and everything goes smoother if the one you use doesn't trip people up.
In this case I pointed him to some documentation on filter and map.
I've been noodling with Julia recently and the dot-syntax is really surprisingly ergonomic for transforming hunks of data. It has the benefit of making vectorization easier for the compiler, but I enjoy the syntax.
That's a cool trick but I'd like an operator with more pixels for that behavior.
When I used to track new languages, I ran into one where the . was implicit. You could change a member of an object from a single value to an array and it would iterate over all of the values. So you could do an information architecture that was 1:1 and change to 1:many later and things would still work.
If you think of 'reduce' as instead 'accumulating' a value, then it takes a function that adds a single element to the accumulation, and a base accumulation to use if the list is empty, and a list, and returns an accumulation of all of the elements of the list in a directional (in this case right-to-left) order.
(accumulate plus 0 lst) is equivalent to (sum lst)
(accumulate multiply 1 lst) is equivalent to (product lst)
(accumulate (lambda (x acc)
(cons (fn x) acc)) '() lst) is equivalent to (map fn lst)
(accumulate (lambda (x acc) (if (fn x) (cons x acc) acc))) '() lst) is equivalent to (filter fn lst)
and so forth. The essential insight is that reduce/fold/accumulate reduces the problem of accumulation to a base accumulation and a function that only has to add a single item (i/o)nto the accumulated value. Using them to directionally processes a container is idiomatic in a functional style.
C++ even calls it std::accumulate. (There’s a std::reduce in C++17, but it’s just std::accumulate with the restriction that the operation is commutative.)
I agree it can be hard to read at first, but after learning the syntax, it's not hard to read. It's also about what the language allows you to do (or prevents you from doing) that determines, to me anyway, the utility. I love Objective-C but it allows you to do a lot of things that can lead to bad code (mostly related to state and hidden side-effects). FP overall is a paradigm that helps prevent you from doing that and helps you rethink and describe problems declaratively.
Haskell is very terse, but it doesn't mean that this is true for every FP language. Code below is in Gerbil Scheme and should be readable even for OOP programmers.
Once you become used to reading it, it doesn't seem like a big deal. But Haskell has a steep learning curve if you have never used functional programming languages before so it may take a while to pick up a few things.
What are some of the better ones to start with? I've tried Erlang and F#, but the hardest part is figuring out whether I'm writing code that's too procedural.
If you're looking for statically typed FP, honestly, start with Elm. You can do it in a weekend it's so conceptually contained. Then F# and OCaml are highly regarded. Avoid Haskell - worst bang for your buck on seeing returns on learning. It has some interesting concepts, but is a terrible language for learning statically typed functional programming.
If you're looking for dynamically typed FP (ex. LISP), go with Clojure, or lesser rec possibly Racket flavor of Scheme.
I don't think you should judge it based on the syntax. With UFCS (Uniform Function Call Syntax), or a pipeline operator, it could easily be rewritten to something like:
This is just pseudocode obviously, but you get the point I hope. There is nothing fundamental to FP about this particular syntax.
Also to be honest, I don't find the original unreadable at all. But that may be because I have more exposure to this style of programming? FP doesn't somehow remove the inherent complexity in tasks like string manipulation.
I don't really like 'where n' afterwards instead of say 'let n' at the beginning, but I'm not very familiar with haskell (really just enough to know the first line is a type signature) and I found this ok. Maybe objective C is better though.
You can write code without ever using `where ...` :)
It's just how mathematicians are used to express their version of "top down problem solving". It's also similar to what you do when you define a function/method before the smaller ones it calls and before the even smaller ones they call (using what's called "function hoisting" in Javascript for example).
Now, if you really dislike "top down problem solving", you might have an allergic reaction to `where ...` which applies the same pattern but does it down even to the micro level of small functions of few lines...
It just says, in code: "replace each line of text with itself prefixed by a number of spaces equal with half of the difference between its length and the length of the longest line in the text".
It's like the most obvious way to think about this particular problem!
Now, how well this scales to different problems, and especially to problems where mutation is a natural way to think about things... that's a different problem and part of the reason I'm not that much in love with extremist functional programming. Mutation has its place and the monadic abstraction is something I dislike.
But for simple examples like this pure FP rocks and is very readable and intuitive!
As usual in FP code, the order of your words are completely different from the order things appear in the code. Also, most developers aren't used to concise function declarations (yeah, even with JS pushing them), what further adds to the problem.
This is not something people with no experience on FP can read easily. Now, calling it illegible strongly implies that people with no FP experience are the metric one should code for, what is perfectly valid if you are at a company aiming to hire cheap coders, but not something to brag about.
Order of words is not that relevant, in English I can say instead of the above:
"take each line of code [map ... xs] and replace it with [\x -> ...] a number of spaces equal to half the maximum line width [replicate (div (n - length x) 2) ' '] prepended to it [... + x]"
All languages have passive voice and other tools so you can make the order of words be whatever you'd want for customizable emphasis while keeping meaning the same.
Sure the order for arguments for `map` may not be the most intuitive, but that doesn't make things harder. See ReasonML for an example of functional programming with regular C-like syntax.
Now, if you want to see hard to read functional code, look into code abusing point free style (I'm not even sure there is a fine way to use it at all...), plus mind bending types (usually to satisfy some monadic style abstractions), plus over-currying stuff all over the place.
But the snippet above is only unreadable for people who stubbornly refuse to invest a couple hours of their time into learning a new notation for things! Heck, you can even translate it almost 1-to-1 to modern Javascript, or even add an extra function name and some more sensible variable names to make things more readable while keeping it functional:
function alignCenter(lines) {
let width = Math.max(...lines.map(ln => ln.length));
let getPadding = len => ' '.repeat(Math.max(0, (width - len) / 2));
return lines.map(ln => getPadding(ln.length) + ln);
}
...the above is more like something I'd actually let pass code review in real life :)
But the example you posted has more to do with whether a language or author has the aesthetics of preferring succinctness and symbols (Perl-like) to words (Ruby-like).
> I think this most effectively demonstrates why I like a lot of OOP: it can be verbose.
Verbosity is not inherently good. In fact, I think verbosity is inherently bad. Have you read much first-year programmer code? It's absurdly verbose at the cost of legibility.
The real issue is clarity. Your code should be sufficiently verbose that its purpose is self-evident, but it should not be overly verbose such that your screen is cluttered with meaningless junk (see: Java).
---
To me, there is a fundamental distinction between the intents of functional programming and imperative programming that a lot of these articles either gloss over or miss completely.
Imperative programming is about describing to the computer a procedure by which to accomplish a goal.
Functional programming is about manipulating the relationships between data directly such that you transform your given input into the desired output.
If you want to understand what a program does, then imperative code is going to be better to read. But if you want to understand what a program means, then (well-written) functional code is going to be better. This is also why so many functional languages have strong static type systems: to better enable the programmer to express programs' meanings outside of the implementation.
However, as others have mentioned, string manipulation is always kind of hairy anyway, so reading this function will result in understanding what it does instead of what it means (unless you just read the signature, which is actually what I do a lot of the time). My thought process of reading this particular function without prior context would be something like:
- The function is named `alignCenter` and takes a list of strings and gives back a list of strings... so the strings are being centered amongst themselves. I know they aren't being centered relative to anything else because there are no other inputs to the function — which I would not know in an imperative language, where there could be hidden state somewhere.
- It maps some function over the list of strings. I assume that function will do the centering.
- This inner function takes a string and does something to it. Let's investigate.
- We replicate a space character some number of times and prepend the resulting string to the input string. (I think the author's reliance on operator precedence is disappointing here, as it detracts from the clarity IMO. I would rather put the `replicate` call in parens before the `++`.)
- The number of spaces is determined by dividing by two some other number less the length of the string.
- That number is the length of the longest string.
So: to center a list of strings, we indent each string by half the difference between its length and the maximum length among all the strings. This seems like a reasonable way to center a group of strings. (Notice that I did not use words like "first", "then", etc. which indicate a procedure. Instead, I have described the relationships of the strings.)
(Of course writing it all out makes it seem like a longer process than it is, but in reality it probably took me 10-15 seconds to go through everything and figure out what was going on.)
---
I think this isn't a great example for the author to have chosen. My go-to example of a beautiful functional solution is generating the Fibonacci sequence.
A "good" imperative solution uses iteration and might look like:
def fib(n: int) -> int:
a = 0
b = 1
while n > 1:
t = a
a = b
b = t + b
n -= 1
return b
If you were to just glance at this code without being told what it does (and if you'd never seen this particular implementation of the algorithm before), you would probably need to write out a couple test cases.
Now, the Haskell solution:
fib :: Int -> Int
fib n = fibs !! n
where fibs = 0 : 1 : (zipWith (+) fibs (tail fibs))
We can easily see that the `fib n` function simply retrieves the nth element of some `fibs` list. And that list? Well it's `[0, 1, something]`, and that something is the result of zipping\* this list with its own tail\* using addition — which very literally means that each element is the result of adding the two elements before it. The Fibonacci numbers are naturally defined recursively, so it makes sense that our solution uses a recursive reference in its implementation.
\*Of course, I'm assuming the reader knows what it means to "zip" two lists together (creating a list by performing some operation over the other two lists pairwise) and what a "tail" of a list is (the sub-list that simply omits the first element).
To me, this data-relationship thing often makes more sense than a well-implemented imperative solution. I don't care to explain to a computer how to do its job; I care to think about how to move data around, and that's what functional programming is all about (to me).
Of course, this is just my subjective opinion, but I think there's some merit to it. I'd like to hear your thoughts!
Here I disagree. To take it to an absurd extreme: LZW compress your source code. Hey, it's less verbose! But that's not a net win.
Instead, I think that there is an optimal value of terseness. More verbose than that, and you waste time finding the point. More terse than that, and you waste time decoding what's going on.
Now, what is "optimal" is going to depend on the reader, both on their experience and their preference. With experience, certain idioms are clear, and don't require thought. The same is true of syntax. (Both Haskell and C become more readable with experience.) But some people are still going to prefer (and do better reading) a more terse style, and others are going to prefer a more verbose style.
I think we have different interpretations of what it means to be "verbose", which is why I instead directed my previous comment towards "clarity".
Wiktionary gives the following definition of "verbose" [0]:
> Abounding in words, containing more words than necessary; long-winded.
My point is that adding words for the sake of adding words is bad, always. It's one thing to say "My code tends to be on the more verbose side of things" and another to say "I prefer writing very verbose code." You should always be seeking to make your code as concise as possible while maintaining clarity.
It's that "while maintaining clarity" bit that's the tricky bit, really. On this, I think we agree. I always try to make my code as short and direct as it can be, but never at the cost of clarity. For example, I don't use cute inlined tricks unless they're idiomatic (or used everywhere in the code and explained in at least a couple places). I try to strive for clarity instead of verbosity.
Ah yeah, I think you're absolutely right: this is much better. It's been a while since I've implemented Fibonacci iteratively haha. Thank you for pointing this out. :)
Personally, I still find the functional version a bit more direct. The iterative code still relies on me understanding what the program is "doing"; it requires me to hold state in my head and think through "okay, now `prev` has this value and `cur` has this other value" to reason about what's going on.
I would be interested to find some people with minimal programming experience and show them imperative vs functional implementations of simple functions or algorithms and see what they prefer. Maybe I'll try to do a small study on that or something.
Fibonacci is usually introduced as "the next term is the sum of the two previous terms" (sometimes with the story about rabbits or whatever). There's two obvious ways to implement this:
1. Direct recursion
2. Keep track of the two previous terms
If you start with (1), you run it and it takes exponential time, and so you should instead remember the two previous terms, leading you to (2). When you go with (2), you either use 2 mutable variables and a loop in the imperative version, or you have 2 accumulators and tail recursion in the functional version... which is the same thing, since a tail recursive function is just a named loop.
There's nothing about laziness or self-referencing an incomplete structure here, which is just a Haskell thing. Taking the tail of an incomplete structure, in particular, is indirect and hard to understand.
If you want to demonstrate laziness, you can still do it directly by doing something like:
fibs a b = a : fibs b (a + b)
fib n = fibs 0 1 !! n