Many programmers try to write clean, intelligent code. However, sometimes, obsession with intelligence may make the code base more difficult to understand and may spend a lot of time reading and maintaining it.
Nowadays, in teamwork, people gradually realize the significance of writing manual code, which means that you should respect others rather than show off your wisdom when writing code. People are trying not to use the word "clean" because it means that the code is dirty even if you don't mean it. Daniel Irvine talks about this in his article "clean code, dirty code, human code".
I'm not saying cleanliness is a bad thing. When dealing with personal projects, I will try to make the code base clean in a smart way. But more importantly, I make the code base more readable and understandable.
As Uncle Bob said in his cleaning Code:
"In general, programmers are very smart people. Smart people sometimes like to show off their smart people by showing off their psychological juggling ability. After all, if you can reliably remember that r is the lowercase version of URL and remove the host and Schema, then you obviously have to be very smart. The difference between smart programmers and professional programmers is that professionals Understanding is king. Professionals do their best and write code that others can understand. "- Robert C. Martin
The most important thing is to write clear and understandable code. Others, not just others, but you, will rewrite the code in a few months.
In this article, I'm not talking about human code. Instead, I'll focus on how to apply this principle to your code through some examples and minimize the time it takes to understand it.
Note: to explain some tips, I'll use JavaScript or TypeScript.
Examples covered:
·Naming
·Notes
·Conditions
·Circulation
·Functions
·Testing
name
In software development, many programmers have trouble naming things. But I personally think the key is to avoid ambiguity and use specific words.
For example:
const fetch = async () => {
return await axios.get('/users')
}
const users = await fetch()
...
In this code, you can expect what to get from the server. However, what if the export function is exported and used in other files?
export const fetch = async () => {
return await axios.get('/users')
}
In other files:
import { fetch } from './utils'fetch()
// fetch ... what?
Instead, you can name it more specifically:
export const fetchUsers = async () => {
return await axios.get('/users')
}
I said you should avoid ambiguity. Note the following general verbs:
· set
· get
· group
· begin
· validate
· send
Another example:
const xxx = validateForm()
In this code, you can understand that validateForm is validating the form, but what do you expect to return?
But suppose you write this:
const xxx = isFormValid()
Then it's very clear that the method will return true or false.
Moreover, if you write the code like this, you can assume that the method will return an array or malformed mapping:
const xxx = getFormErrors()
Another example:
const token = getToken()
As you can see, getToken may get a token. But from what? What if it uses asynchronous functionality to get tokens from the server?
const token = getToken()
// use token for somethingdoSomething(token)
This may cause undefined errors in the doSomething function, because in this case, you need to wait for getToken to complete.
const token = await getToken()
// use token for somethingdoSomething(token)
It works normally. However, getToken is not appropriate in this case, so you can rename it:
const token = await fetchToken()
// use token for somethingdoSomething(token)
In this way, it is more clear that the method will obtain tokens from some servers or asynchronous devices.
In order to solve these problems, many smart people have put forward a good example, but it is important to let people know its purpose in a simple way.
notes
Generally, the purpose of annotations is to help people understand the code as much as possible, and annotations can make people understand the code faster. But you don't always have to comment on the code. You need to know the line between worthless comments and good comments.
What? Nothing to comment on
If people can easily understand the function of the code, there is no need to comment on the code.
For example:
// Find student from lists, with the given id
const student = students.find(s => s.id === id)
Another example:
// Calculate tax based on the income and wealth value and ....
const income = document.getElementById('income').value;
const wealth = document.getElementById('wealth').value;
tax.value = (0.15 * income) + (0.25 * wealth);
// ...
This seems to be a good comment to explain what it is, but it can be improved.
function calculateTax(income, wealth) {
return (0.15 * income) + (0.25 * wealth);
}
You should move the code block into the function and enter a name to explain its function. Concise function names and self recording functions are better than comments.
Note what
We introduced the types of code that you should not comment on. Next, we'll see what should be annotated.
You need to add comments at the following code:
·Defects, such as performance problems
·May lead to unexpected behavior
·A summary is needed so that people can easily grasp the details
·When you need to explain why there is a better method, you must write it in this way
These are valuable insights you come up with when writing code. Without these comments, people may think there are errors, or the code should be tested or repaired, which may waste time. To avoid this, you should explain why you write code in some way.
The important thing is to let yourself put on other people's shoes. Think ahead and predict the pitfalls people may fall into.
Deal with the positive first, not the negative
Which is better for you to read?
if (!debug) {
// do something
} else {
debugSomething()
}
or
if (debug) {
debugSomething()
} else {
// do something
}
In most cases, positive cases are preferred. However, if the negative situation is simpler and more cautious, it can be written as follows:
if (!user)
throw new Error('Please sign in first')
// do a lot of things here
// ...
Return early
For example:
export const formatDate = (date) => {
let result
if (date) {
const dateObj = new Date(date)
if (isToday(dateObj)) {
result = 'Today'
} else if (isYesterday(dateObj)) {
result = 'Yesterday'
} else if (!isThisYear(dateObj)) {
result = format(dateObj, 'MMMM d, yyyy')
} else {
result = format(dateObj, 'MMMM d')
}
} else {
throw new Error('No date')
}
return result
}
It works, but the code is a little long and nested. Moreover, if you add an if / else statement, it will be more difficult to figure out where the closing parenthesis is and to debug the code.
In order to make it look cleaner, what we need to do is:
·If there is no date, an error is thrown
·Returns today if the date is today
·If the date is yesterday, return yesterday
·If the date is not this year, the date and year are returned
·If the above conditions are not met, the date and month and date are returned
export const formatDate = (date) => {
if (!date) throw new Error('No date') // If no date, throw an error
const dateObj = new Date(date)
if (isToday(dateObj)) return 'Today' // If the date is today, return 'Today'
if (isYesterday(dateObj)) return 'Yesterday' // If the date is yesterday, return 'Yesterday'
if (!isThisYear(dateObj)) return format(dateObj, 'MMMM d, yyyy') // If the date is not in this year, return date with year
return format(dateObj, 'MMMM d') // If no matching the above, return date with month and date
}
Looks better. Multiple returns from a function are ideal for making the code readable.
Working with multiple cases using Array.includes
If you have multiple conditions, you can use Array.includes to avoid extending statements.
For example:
if (kind === 'Persian' || kind === 'Maine' || kind === 'British Shorthair') {
// do something ...
}
Considering that other conditions can be added to the statement in the future, we want to refactor the code as follows:
const CATS_TYPE = ['Persian', 'Maine', 'British Shorthair']
if (CATS_TYPE.includes(kind)) {
// do something ...
}
With an array of types, you can extract conditions separately from your code.
Use optional links to handle undefined checks
Optional links allow you to drill down into nested objects without having to repeatedly allocate results in temporary variables. By using this option, you can reduce the number of checks in conditional checks.
Note: if you want to use the optional link operator in JavaScript, you need to install the Babel plug-in. In Typescript above 3.7, it can be used without any configuration.
For example:
if (user && user.addressInfo) {
let zipcode
if (user.addressInfo.zipcode) {
zipcode = user.addressInfo.zipcode
} else {
zipcode = ''
}
// do something
}
If you want to check whether the user exists and avoid undefined errors, you need to write conditions similar to the above example.
However, by using the optional link operator, the code will be:
const zipcode = user?.addressInfo?.zipcode || ''
This looks better and easier to maintain. You can access nested objects inside and avoid undefined errors.
You can use this cool feature in TypeScript playground
loop
Simplifying loops makes your code easier to understand.
In practice, you may encounter complex nested loops in objects. What if you have nested objects and you have to get the list name in todo3:
const todos = [
{
code: 'code',
name: 'name',
list: [
{
name: 'todo name',
},
{
name: 'todo name',
},
],
todo2: [
{
code2: 'code2',
name2: 'name2',
list: [
{
name: 'todo name2',
description: '',
},
{
name: 'todo name2',
description: '',
}
],
todo3: [
{
code3: 'code3',
name3: 'name3',
list: [
{
name: 'todo name3',
description: '',
},
{
name: 'todo name3',
description: '',
}
]
}
]
}
]
},
]
For example, you can write:
const list = [];
todos.forEach(t => {
t.todo2.forEach(t2 => {
t2.todo3.forEach(t3 => {
t3.list.forEach(l => {
list.push({
todo3Name: l.name
})
})
})
})
})
However, this can be improved by using the reduce function:
const list = todos
.reduce((acc, t) => [...acc, ...t.todo2], [])
.reduce((acc, t2) => [...acc, ...t2.todo3], [])
.reduce((acc, t3) => [...acc, ...t3.list], [])
.map(l => ({ todo3Name: l.name }))
function
When writing functions, keep the following tips in mind:
·Use the summary name to describe its operation.
·Create a function for a purpose.
·Smaller functions are easier to read.
Use the summary name to describe its operation
At first glance, the code below is as follows, and you may stop reading and try to figure out what it is doing:
const tmp = new Set();
const filtered = lists.filter(a => !tmp.has(a.code) && tmp.add(a.code))
What about that?
const filtered = uniqueByCode(lists)
You might expect a function to delete objects with duplicate code by viewing objects.
Both work well and get the same results.
But the second is more readable and can help explain the function.
Another example:
const person = { score: 25 };
let newScore = person.score
newScore = newScore + newScore
newScore += 7
newScore = Math.max(0, Math.min(100, newScore));
console.log(newScore) // 57
If we write a function for it, the code will be as follows:
let newScore = person.score
newScore = double(newScore)
newScore = add(newScore, 7)
newScore = boundScore(0, 100, newScore)
console.log(newScore) // 57
Much better, but personally, I like the idea of the pipeline operator, which is used with linking multiple functions together to improve the readability of functional programming.
If we use pipes in JavaScript, the code will be as follows:
const person = { score: 25 };
const newScore = person.score
|> double
|> add(7, ?)
|> boundScore(0, 100, ?);
newScore //=> 57
Create features for one purpose
Now we understand the importance of the summary name. But what if you can't give your feature a good name?
For example:
const updateUser = async (user) => {
try {
await axios.post('/users', user)
await axios.post('/user/profile', user.profile)
const email = new Email()
await email.send(user.email, 'User has been updated successfully')
const logger = new Logger()
logger.notify()
} catch (e) {
console.log(e)
throw new Error(e)
}
}
You might want to know whether it should be updateUserAndProfile, updateUserAndProfileAndNotify, or something else. When you're in trouble, it's time to divide the code into smaller parts, because it's difficult for people to understand multiple pieces of code at the same time.
When you write a function to update users, the code should be as follows:
const updateUser = async (user) => {
try {
await axios.post('/users', user)
} catch (e) {
// handling error
}
}
const handleUpdate = async (user, onUpdated) => {
try {
await updateUser(user)
await updateProfile(user.profile)
await onUpdated(user) // email or notify something
} catch (e) {
// handling error
}
}
This is a very simple example, but there are many cases in actual development. The key idea to keep in mind is to step back, think about what the function should do, and consider all the issues so that you can perform only one task at a time.
Smaller functions are easier to read
When you write smaller functions for a purpose, the code will be more readable and understandable.
For example:
const generateQuery = (params) => {
const query = {}
try {
if (params.email) {
const isValid = isValidEmail(params.email)
if (isValid) {
query.email = params.email
}
}
const defaultMaxAgeLimit = JSON.parse(localStorage.getItem('defaultMaxAgeLimit') || '')
if (params.maxAge) {
if (params.maxAge < 25) {
query.maxAge = params.maxAge
}
} else {
query.maxAge = defaultMaxAgeLimit
}
if (params.limit) {
query.limit = params.limit
}
// do a lot of things here
// ...
} catch(err) {
// error handing
}
return query
}
Suppose you have a lot of code in a function that creates a query to search for some data. If there is something wrong with the e-mail query, you must browse the internal functions, find the e-mail implementation and fix it. After that, you will have to check whether the changes affect other code in the function.
Usually, people can only think about two things at a time. The larger the code representation, the more difficult it is to understand and maintain.
Therefore, make the code smaller:
const email = (query, params) => {
if (!params.email) return query
const isValid = isValidEmail(params.email)
if (!isValid) throw new Error('Invalid email')
return { ...query, ...{ email: params.email } }
}
const maxAge = (query, params) => {
const obj = { maxAge: '' }
if (!params.maxAge) obj.maxAge = JSON.parse(localStorage.getItem('defaultMaxAgeLimit') || '')
if (params.maxAge && params.maxAge < 25) obj.maxAge = params.maxAge
return { ...query, ...obj }
}
const limit = (query, params) => {
if (!params.limit) return query
return { ...query, ...{ limit: params.limit } }
}
const generateQuery = (params) => {
let query = {}
try {
query = email(query, params)
query = maxAge(query, params)
query = limit(query, params)
} catch(err) {
// error handing
}
return query
}
Breaking down large pieces of code into small pieces can make the code clearer and easier to read. More importantly, each problem is separated from the rest of the code, so you can easily debug and test it.
If you also want to make it more generic and declarative, you can write it this way:
const generateQuery = (params, callbacks) => {
let query = {}
try {
callbacks.forEach(c => {
query = c(query, params)
})
} catch(err) {
// error handing
}
return query
}
const result = generateQuery({ email: 'xxx@gmail.com', maxAge: 20 }, [email, maxAge, limit])
Although I suggest that we want to make the code smaller and more general, before refactoring, you will have to first consider why you write this code in this way. Maybe your colleague wrote it for some reason. Even if there are many improvements, you also want to talk to the person who wrote it and discuss whether it is good.
It's all about manual passwords. You can learn more about it in the article "goodbye, clean code".
Under test
I'm not talking about TDD, but the importance of readability to testing in team development.
Writing tests is important because:
·Without documentation, your teammates can easily learn more by reading the test instructions.
·Your teammates can understand how real code should work and why.
·Your teammates can easily add new features without worrying about breaking the code.
·Encourage your teammates to add tests. (if the test code is too large and daunting, it may break the window!)
These are my personal experiences and lessons in team development. Good programmers always write tests with good maintainability.
Here are some tips for writing tests:
·Describe the purpose of the test in simple English (preferably in your native language).
·Follow AAA (schedule, execute, declare) mode.
·Make it easy to add test cases (table driven test patterns).
Describe in simple English what the test will do
As I said above, if the test is descriptive, people can easily understand what it is doing.
Suppose we have a util like function:
export const getAnimal = (code) => {
if (code === 1) return 'CATS'
if (code === 2) return 'DOGS'
if (code === 3) return 'RABBITS'
return null
}
And write a test:
import { getAnimal } from './util';
describe('getAnimal', () => {
it('passes', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
This is a very simple example, so you may understand what it is trying to do. However, if it gets bigger and messy, it will be difficult for you to understand it.
It doesn't matter, because you have written this feature and know its function. But the test is not only for you, but also for your teammates.
Let's be more descriptive:
describe('getAnimal', () => {
it('should get CATS when passing code 1', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
This seems a little redundant. But that's not the point. By describing it, you can let people know that the correct behavior is to get CATS when the code is 1.
To make it clearer in each context, you can use context blocks as follows:
describe('getAnimal', () => {
context('when passing code 1', () => {
it('should get CATS', () => {
expect(getAnimal(1)).toEqual('CATS')
})
})
})
Note: if you use Jest, you can install Jest plugin context.
By writing this way, you can separate specific contexts in each block.
Follow AAA mode
AAA mode allows you to divide the test into three parts: scheduling, operation and declaration.
In the scheduling section, you can set up data or simulate the functions to be used in the test.
The act part is where the test method is called and the output value is captured when needed.
The assert section is where you declare the output.
If you apply it to the above example, the code will be as follows:
describe('getAnimal', () => {
context('when passing code 1', () => {
beforeEach(() => {
// arrange
// prepare data here
})
it('should get CATS', () => {
// act
const result = getAnimal(1)
// assert
expect(result).toEqual('CATS')
})
})
})
This is another example of a reaction test using an enzyme:
describe('Component', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
// arrange
// mock useEffect function
jest
.spyOn(React, 'useEffect')
.mockImplementation(f => f());
});
it('should render successfully', () => {
// act
wrapper = mount(<Component {...props()} />);
// assert
expect(wrapper).toMatchSnapshot();
});
it('should update the text after clicking', () => {
// act
wrapper = mount(<Component {...props()} />);
wrapper.find('button').simulate('click');
// assert
expect(wrapper.text().includes("text updated!"));
});
});
Once you get used to this model, you can read and understand the test more easily.
On GitHub, JavaScript testing best practices is a good guide for explaining JavaScript testing.
Easily add test cases (table driven test pattern)
In Go testing, table driven test mode is often used. Its advantage is that it can cover many test cases by defining the input and expected results in each table entry.
This is an example of the fmt package in Go:
var flagtests = []struct {
in string
out string
}{
{"%a", "[%a]"},
{"%-a", "[%-a]"},
{"%+a", "[%+a]"},
{"%#a", "[%#a]"},
{"% a", "[% a]"},
{"%0a", "[%0a]"},
{"%1.2a", "[%1.2a]"},
{"%-1.2a", "[%-1.2a]"},
{"%+1.2a", "[%+1.2a]"},
{"%-+1.2a", "[%+-1.2a]"},
{"%-+1.2abc", "[%+-1.2a]bc"},
{"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
var flagprinter flagPrinter
for _, tt := range flagtests {
t.Run(tt.in, func(t *testing.T) {
s := Sprintf(tt.in, &flagprinter)
if s != tt.out {
t.Errorf("got %q, want %q", s, tt.out)
}
})
}
}
The input and expected output are defined in the flagtests variable. All you have to do is cycle through, run the test, and check the results.
You can apply it to JavaScript tests.
For example:
import { getAnimal } from './util';
describe('getAnimal', () => {
context('when passing code 1', () => {
it('should get CATS', () => {
const result = getAnimal(1)
expect(result).toEqual('CATS')
})
})
context('when passing code 2', () => {
it('should get DOGS', () => {
const result = getAnimal(2)
expect(result).toEqual('DOGS')
})
})
context('when passing code 3', () => {
it('should get RABBITS', () => {
const result = getAnimal(3)
expect(result).toEqual('RABBITS')
})
})
context('when no match', () => {
it('should get null', () => {
const result = getAnimal(100)
expect(result).toEqual(null)
})
})
})
And make it applicable:
const cases = [
{
code: 1,
expected: 'CATS',
},
{
code: 2,
expected: 'DOGS',
},
{
code: 3,
expected: 'RABBITS',
},
{
code: 100,
expected: null,
},
]
describe('getAnimal', () => {
cases.forEach(c => {
context(`when passing code ${c.code}`, () => {
it(`should get ${c.expected}`, () => {
const result = getAnimal(c.code)
expect(result).toEqual(c.expected)
})
})
})
}
This technique is useful if you have many test cases.
conclusion
I showed you how to make the code easy to understand through some examples. Remember, clean, intelligent code is not always better. It's important to step back and ask yourself, "it's cleaner, but can you understand and read?" or "can other teammates maintain it?" if so, you can do it.
I hope this article is helpful to you.
If you have any suggestions, comments and ideas, please let me know.
(this article is translated from the article Clarity Is King When Writing Code by Manato Kuroda, reference: https://medium.com/better-programming/clarity-is-king-when-writing-code-752b85101484)