Summary:
As computer scientists, we love abstraction. We hide adder gates behind ALUs, ALUs behind CPU instructions, code reordering behind CPU and compiler guarantees...
When want to do something complex, we bury everything that operates at a lower level; we abstract interfaces.
There's one kind of abstraction that I think is a bit less intuitive: In a single file, code should be organized in a top-down layout such that the first thing you see is 'the punchline', the high-level idea, not the guts. If you name your functions well, you won't need to see what getConnectionInfo
does in order to understand the createClientSocket
function that invokes it. What you will do, is hide the details so that you can focus on what's important; that's abstraction.
Take advantage of the information-hiding value of functions!
I think that the best way to have this sink in, is going to be to walk through an example. I'm not actually going to show you any code, quite frankly, it'd get in the way.
The Scene:
I did a demo of basic socket code this week in my OS discussion section, so I'll use that as my example; the point of the demo was to demonstrate how to set up a server and client and make them talk.
So imagine that we're doing a code review and I'm a colleague explaining my code to you. I'll do this two ways: top-down and bottom-up, and I'll also talk about 'spatial locality' as a third option at the end - read the next two sections in any order you like, we'll analyze what we like and what we dislike and why.
Bottom-up:
In getConnectionInfo
, we fill out some connection details and return them.
In createClientSocket
we then use getConnectionInfo
to tell us who to connect to, create a socket, we then connect, and return the socket.
In createServerSocket
we also use getConnectionInfo
to tell us how to let people connect, we then create a socket, bind the socket, start listening for clients and return the socket.
In clientStart
we send a message to the server. We then wait for the server to respond with an acknowledgement, and then we clean up.
In serverStart
we wait for a client to connect. We then wait for them to send us a message, and when they do, we send back an acknowledgement message. Finally, we clean up.
The program starts up in main
. We call createServerSocket
to open a socket for the server to listen on and then we call createClientSocket
to create and connect a socket to our server. We then fire up a thread for the server, starting in serverStart
and call clientStart
in the original thread.
Top-down:
The program starts up in main
. We call createServerSocket
to open a socket for the server to listen on and then we call createClientSocket
to create and connect a socket to our server. We then fire up a thread for the server, starting in serverStart
and call clientStart
in the original thread.
In serverStart
we wait for a client to connect. We then wait for them to send us a message, and when they do, we send back an acknowledgement message. Finally, we clean up.
In clientStart
we send a message to the server. We then wait for the server to respond with an acknowledgement, and then we clean up.
In createServerSocket
we call getConnectionInfo
to tell us how to let people connect, we then create a socket, bind the socket, start listening for clients and return the socket.
In createClientSocket
we call getConnectionInfo
to tell us who to connect to, create a socket, we then connect, and return the socket.
Finally, in getConnectionInfo
, we fill out some connection details and return them.
Analysis:
In the top-down version - I start by telling you about what the program does. This is the highest level of abstraction.
I tell you that we make a server and a client socket and then start both a server and a client thread. This is a high level picture of what's happening. I contextualize what's about to happen so that you don't need to know all of the details to understand the code - you can guess and fill things in as needed.
We then dive into what the server and client actually do - the client sends some stuff and waits for an ack - the server does the opposite. If I'm updating/editing this code, I don't really care what getConnectionInfo
is most likely - I'm interested in the semantics of the client or the server, but not the low-level details; abstract them away!
If we look at the bottom-up version, I start by telling you about getConnectionInfo
- ok - I've basically said "here's some stuff you can connect to someone with". Great - I get to throw that into my mental cache for a while until I finally get the context to make this meaningful. I may have seen this piece of code by this point, but I can't contextualize it!
Ok - we go up a level - I tell you about making client and server sockets. At this point, you're filling in high-level details, such as 'there's a client and a server', instead of top-down where you filled in and abstracted low-level details. You could say that from bottom-up, I've abstracted "what the code actually means/is/does"; that seems silly.
It takes us until we hit the absolute bottom of the file to get a clear picture of what this program actually does.
The one conceivable advantage of doing things bottom-up is that if we still remember it, I know exactly what getConnectionInfo
actually is/does when I see the call sites. I reject the premise that it will be remembered in a large file, and I don't think it buys you much even if you did; name your functions well and you can infer the details anyway.
Conclusions:
I think this is pretty clear-cut: with bottom-up, you hide the big-picture. I can revise my getConnectionInfo
to pick a new address, but I don't know how it's being used (or if it's being used) higher up. I don't even know that this is the best way to achieve my task. If I knew what my code was supposed to do, I might sooner download a library than dive into details.
With top-down, I get a sense of what's supposed to be happening before I encounter the guts. I'm also not handicapped by what I don't see - if I need some details, I'll elect to find the ones I care about, I'll cache only those details in my brain, and I'll go back to accomplishing my high-level task.
Top-down wins.
But - wait - what if I went for 'spatial locality'?
Some of you might say - well wait - I can organize things so that all of the client functions are together and all of the server functions are together.
Ok - I can see how that might be a win - if nothing else, it means you don't have to jump around the file. You lose the nice 'I see the big picture' aspect, however; you've just cut your view in half.
There's some gain, and some loss. For a program this small, I don't think the spatial locality buys you much. Our text editors can open multiple panes at once if you're truly concerned about scrolling.
In a bigger project, where the server and client are more complex - you'd probably have them as separate programs, not even just separate in the codebase as different modules, and the threshold where you make decisions like that is non-trivial to find. While server and client lend themselves more to this sort of separation (in which case I think external documentation in the form of a wiki page starts to sound shiny), most of the time, code isn't so cleanly divided - and I argue that top-down wins over spatial locality.
Recommendations:
Think of your functions as having a tree relationship. Your main function is your root. The things that it calls are its children, and the things that those functions call are their children.
We can then define a 'level of abstraction' (roughly) as a level in the tree, in other words, by what a nodes depth is from the root (main or the high level idea of the module).
I propose that the best ordering is level-order. Start listing functions in your source file at the highest level of our tree, end at the lowest.
There's a couple of things to mention here:
1) It's probably not quite a tree - you might have multiple functions on one level referencing something on the next, like getConnectionInfo
, etc.
2) You might propose a scenario where I have a helper function that is only ever called by main - it's a detail, but I use it early on, and that this breaks the notion of 'levels of abstraction'. You got me - you're right - do it anyway. Put it as the last function on that level - you'll get your high level overview and still get the relevant details without having to go too far. The difference between bottom-up and this is that by the time you see this 'detail function', you have the context that explains its high-level purpose and you can decide to skip past it safely.
Credit where credit's due:
The idea for this article comes from page 27 of the C++ Coding Standards document document from University of Michigan Professor, Dr. Kieras' EECS 381 course. I'm writing this because I don't think that document does it justice. It contains a short paragraph description that is incomplete without context. I only realized why the top-down approach is so beneficial after getting a red pen taken to my code and talking about it.