The good old days!
If you are a Software Engineer, there’s a strong chance that you’ve invested a fair amount of time into understanding some aspects of code. Whether the function name might be too generic, not helping with the process, or the implementation details unclear or overly complicated, hiding its purpose and functionality.
Of course, from the outside, it seems to be a fairly easy task to write understandable code, right?
The problem is, that we, as Software Engineers, are getting lost in our implementation details and we don’t focus enough on readability and simplicity. At least, those wild west days of Software Development, where the more obscure code you write the better of a programmer you are, are over. Nowadays, we understand that code is written for people who need to maintain and understand it in a reasonable time.
KRES vs KISS — Keep it readable, easy and simple (but not stupid)
The goal should be to write as readable and easy to understand code as possible. If, at the end you look at your code and think ‘how did this simple code take me so long to develop?’, you might just have abstracted away the complexity. Often, when you’ve developed a complicated feature, you’ll find that the lines of code are relatively simple. The reason for this is that the best way to solve a difficult problem is to break it down into simple steps and writing simple code — a very underrated skill in software engineering.
Keep in mind!
The difference between a good and bad programmer’s code is the correctness in solving a particular problem. The difference between an average programmer and a great programmer is not how fast they can finish their backlog but the readability of the code. Both, assuming that its correctness is a given.
Understanding that maintainable and readable code makes the difference is key and worth investing some more time adding to the completion of a feature.
Keeping everything you need to know close together (cohesion)
You want to make sure that if someone reads your code, the person shouldn’t have to jump around in your code – that makes it more difficult to get the full picture and understand the functionality.
Try to solve this through good abstraction instead.
Abstraction for sanity
The brain needs more resources to understand flat-complex functionality as opposed to providing a high-level overview with the possibility to look up further details.
Give your code’s reader just the right amount of information to understand what is happening. If the reader requires more low-level knowledge that’s fine, they can get into specific implementations.
You want to make sure that the flow is clear and easy to read – there should always be a difference between flow and implementation details.
For example, all the high-level-functions could be top of the class, while the implementation-details are defined below.
E.g.
func verifyCredentials(_ credentials: Credentials) {
let encoded = encode(credentials)
validate(encodedCredentials: encoded) { [weak self] result in
switch result {
case .success(): self.userAutherized = true
case .failure(): self.userAutherized = false
}
}
Here we see a clear function that has defined steps that are clear and where the reader can easily understand what is happening.
This method calls within the function might be done by a service but for clarifying the example, it’s done in a declaration at the bottom. If the reader wants more information they can check it, but for the most use cases, the top should clarify enough.
The reader first looks at the code in this function and gets a high-level understanding of what’s going on. Then, they can decide to go deeper into the details by reading the code of those functions.
We can compare this with a browser – you write your URL and the browser shows the website.
For most of the users it is not important that, under the hood, the browser looks up the IP address and requests the HTML, CSS, and resources that it afterward renders.
In this case, all of the details are well abstracted and make it easy for a user to process the flow.
You end up having a tree-like structure of abstraction – where the different levels represent the number of details and complexity. The root of the tree would be the interface of the class/service, which should be enough for most of the cases. Going down the tree would lead to more implementation details.
In the visualized tree you can see the abstractions in action.
On top, you can find the functionality that the end-users are seeing. It is the browser interface as we all know it. The user inputs a URL and sees the website showing up.
Below it shows the abstractions and who should know about the components of the abstraction groups:
Different layers of tests
The abstraction above also makes it very clear how to test your code. You start testing the overall functionality. The browser example is great here because it shows the differentiation between UI and Business Logic. When entering a URL, our service won’t return a rendered website, but rather the components that make up the website which the renderer will put together.
Following that example, we can test the entire business logic by passing a URL to our service and expect the mocked resources back (HTML, CSS, Images, etc.). This would be a high-level test that would cover the integration of all the underlying logic while the low-level implementation can be mocked.
The next step would be testing at one lower level and its interface/exposed functionality. An example could be the DNS lookup, here we would pass the URL and receive back the IP address.
Going into code – here we see the difference in code.
Instead of:
class WebsiteService {
func lookupIpforUrl(url: URL, completion: (Result<String, Error>) -> () ) {
let payload = [
"url": url
]
NetworkService.shared.post(to: url, with: payload) { result in
switch result {
case .success(let responseData):
let ip = responseData.mapTo(IPLookupResponse.self)
completion(.success(ip))
default: return
}
}
}
func fetchHtml() -> Data? {
//...
return nil
}
func fetchResources(from html: Data) -> Data? {
//...
return nil
}
func applyUI(from css: Data) {
//...
}
}
You could do:
class BrowserViewController: UIViewController {
private let webFetcher: WebFetcherProtocol
private let renderEngine: RenderEngineProtocol
init(webFetcher: WebFetcherProtocol, renderEngine: RenderEngineProtocol) {
self.webFetcher = webFetcher
self.renderEngine = renderEngine
super.init(nibName: nil, bundle: nil)
}
func showWebsite(for url: URL) {
webFetcher.fetchWebsite(for: url) { [weak self] websiteData in
self?.view = renderEngine.renderedView(with: websiteData)
}
}
}
The second example uses a simpler abstraction and shows how much easier it is to read and understand what the snippet is doing.
What has TDD to do with all of this?
This testing and creating of abstractions is what TDD is all about. It sounds like it’s all about testing, but it’s actually about creating different layers of abstraction.
You start thinking about your code from a high level and work yourself lower to the details rather than starting with the implementation details (this way is also called outside-in).
Before you write a line of production code, define the test cases starting from the highest abstraction level. In our example, that would be something like:
func testBrowseWebsite() {
browser.show(url: www.google.com)
assertTrue(browser.website.isShown)
}
This way of writing code helps to create the right abstractions because you write the code in a way that you can also read it without having implementation details in mind.
More about TDD can be found here.
Great post it is really needed for new software engineer who have started their career. Most of the beginners as well as experienced software engineers make this mistake and this is very helpful post for them. Thanks a lot for sharing this.