As a Developer, We know that functions are block of codes that perform a specific task, like we use to create functions for finding a prime number, palindrome number, number of spaces in a string, etc.
We use functions for organizing codebase, maintaining readability, ease in debugging, but, do you know what principles should be followed while creating functions?
Lets look at them!
😻 Important Tips:
- Don't focus on language used in the examples! Just go through the code and see what it does.
- Don't try to understand what each line of code does! Just a high level overview is enough to continue.
"Do One Thing" Rule
It is a fundamental principle that emphasizes on "A function should do only one thing. It should do it well. It should do it only."
It states that a function should have a single, clear responsibility and should perform one and only one task.
Example:
public class ShoppingCart {
private List<Item> itemList = new ArrayList<>();
private double totalPrice = 0.0;
public void addItem(Item item) {
itemList.add(item); // Add the item to the cart
totalPrice += item.price; // Update the total price
System.out.println("Added item: " + item.name); // Log the action
}
}
Here the function addItem()
does more that one task, like updating total price and logging the addition of the item. Lets refactor it!
public class ShoppingCart {
private List<Item> itemList = new ArrayList<>();
private double totalPrice = 0.0;
public void addItem(Item item) {
items.add(item);
updateTotalPrice(item.price);
itemLogger("Item Added :",item.name);
}
private void updateTotalPrice(double itemPrice) {
totalPrice += itemPrice;
}
private void itemLogger(String msg, String itemName) {
System.out.println(msg + " " + itemName);
}
}
Following the "Do One Thing" rule, we split the function into three separate methods: addItem, updateTotalPrice, and itemLogger. Now each function now has a single responsibility, making the code more modular and easier to understand.
You might think, we created so many unnecessary functions to separate just a single line of code, but it was just a simple example. Lets take a look at some complex ones.
Example: An Email Validator
public class EmailValidator {
// Please do not focus on what each line of code does!!!
public boolean validateEmail(String email) {
if (email != null && !email.isEmpty() && email.contains("@") && email.contains(".")) {
String[] parts = email.split("@");
if (parts.length == 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) {
String[] domainParts = parts[1].split("\\.");
if (domainParts.length > 1 && !domainParts[0].isEmpty() && !domainParts[1].isEmpty()) {
return true;
}
}
}
return false;
}
}
Refactored Code:
public class EmailValidator {
public boolean validateEmail(String email) {
if (isValidFormat(email)) {
return isValidDomain(email);
}
return false;
}
private boolean isValidFormat(String email) {
return email != null && !email.isEmpty() && email.contains("@") && email.contains(".");
}
private boolean isValidDomain(String email) {
String[] parts = email.split("@");
if (parts.length == 2) {
String[] domainParts = parts[1].split("\\.");
return domainParts.length > 1 && !domainParts[0].isEmpty() && !domainParts[1].isEmpty();
}
return false;
}
}
Here, you can see that the code is much more readable, easy to understand and easy to debug also.
One Last Example:
public class StringManipulator {
public String manipulateString(String input, boolean toLowerCase, boolean removeWhitespace) {
String result = input;
if (toLowerCase) {
result = result.toLowerCase();
}
if (removeWhitespace) {
result = result.replace(" ", "");
}
return result;
}
}
// ------------------------------------------
// Compare the above class with the below one
// ------------------------------------------
public class StringManipulator {
public String toLowerCase(String input) {
return input.toLowerCase();
}
public String removeWhitespace(String input) {
return input.replace(" ", "");
}
}
To ensure your functions follow the "Do One Thing" rule, ask yourself the following questions:
- Does the function have a clear and concise purpose?
- Does the function's name accurately reflect what it does?
- Does the function perform any unrelated or secondary tasks?
- Can the function be further broken down into smaller, more focused functions?
- Is the function's behavior easily testable in isolation?
One Level of Abstraction per Function
It means function should either deal with high-level concepts or delegate lower-level details. For example, if a function is checking weather a number is prime or not, then other tasks like, error or result printing should not be done within that function.
Lets look at a better example: Consider a FileUploader class
Messy Code:
public class FileUploader {
// A function to upload a file
public void uploadFile(File file) {
if (fileExists(file)) { // checks file exists
if (isValidFileType(file)) { // checks file is of valid type
// -----
// -----
// consider few lines for connecting to server
// ----
// ----
if (isConnected()) { // if connected to server
upload(file); // upload file
closeConnection();
} else { // when connection is not established
logConnectionError();
}
} else { // when file is not of valid type
logInvalidFileType();
}
} else { // when file is not found
logFileNotFound();
}
}
}
In the above code, we check if file exists, is it valid or not, then we try to make connection to server & along side handle its error, then finally upload the file.
Let's Refactor This Code:
public class FileUploader {
public void uploadFile(File file) {
if (!fileExists(file)) { // if file is not found
logFileNotFound();
return;
}
if (!isValidFileType(file)) { // if file is not valid
logInvalidFileType();
return;
}
if (!connectToServer()) { // if server is not connected
logConnectionError();
return;
}
upload(file); // upload the file
closeConnection(); // close the server
}
}
Here, the code is much more cleaner, easy to go through, and we can see that we just have a function connectToServer()
which returns boolean
when connection is made or failed.
The code for connecting to server should not be here even it is a crucial part for uploading file. Since it was another deeper layer of abstraction like file existence checking & validity checking, so, we should separate that out.
We are not using isConnected()
and not logging its error here, because it should be handled within connectToServer()
.
Another Example for clarity:
public class ShoppingCart {
public double calculateTotalPrice(List<Item> items) {
double totalPrice = 0.0;
for (Item item : items) {
totalPrice += item.getPrice();
}
if (totalPrice > 100) {
totalPrice = totalPrice - (totalPrice * 10 / 100); // Apply 10% discount
}
return totalPrice;
}
}
What according to you is a problem here ?
Problem: Applying discount is another level of abstraction and that part of code should be in different function.
😻 Important Tips:
- Don't think that we can separate the discount part so we should do it but rather think like it is another detailed task whose logical operation & error handling should be done in different function.
Refactored Code:
public class ShoppingCart {
public double calculatePriceToBePaid(List<Item> items) {
double totalPrice = calculateBaseTotalPrice(items);
return applyDiscount(totalPrice);
}
private double calculateBaseTotalPrice(List<Item> items) {
double totalPrice = 0.0;
for (Item item : items) {
totalPrice += item.getPrice();
}
return totalPrice;
}
private double applyDiscount(double totalPrice) {
if (totalPrice > 100) {
totalPrice = totalPrice - (totalPrice * 10 / 100); // Apply 10% discount
}
return totalPrice;
}
}
Here we go! Now we have much more clarity on the abstraction.
Story Telling
Think of your code as a narrative that tells a story about how the program works.
Just as a story has a clear structure with a beginning, middle, and end, your code should have a logical flow that progresses from high-level concepts to lower-level details. From reading the code from top to bottom it should clearly specify how the flow works.
If you go through the above refactored codes, you will notice that by just having a glance from top to bottom you can clearly understand the flow of program.
Descriptive Names For Functions
Here is a previous blog post (LINK) regarding how to name variables, functions, classes and so on.
Lets look at the glimpse of it:
-
Methods should have verb or verb phrase names that clearly indicate the action or operation they perform.
-
Additionally, accessors (methods that retrieve data) and mutators (methods that modify data) should be prefixed with "get" or "set" respectively. Example:
getUserDetails()
,setUserDetails()
. -
Predicates (methods that return boolean values) should also have names that convey the question they answer. Example:
isEven()
,isPrimeAndPalindrome()
. -
Don't be afraid to give function a long name.
Long Argument List
Try to avoid functions that take more than 3 arguments by refactoring the function to receive single object or structure containing the whole data.
**For Example: **
public void function saveUserData(int id, int age, String name, String address, String password){
// ...
}
// VS
public void function saveUserData(User user){
// ...
}
In the first function, we were sending all data one by one, and in the next one we just send the whole object or structure containing those data. Now with that user
object we can access all the necessary data that we need to use inside the function.
😻 Important Tips:
- A function must do what its name suggests. For example:
validateUser()
will only check if user credentials are valid or not. It will not do any actions like manipulating user data or initializing session for that user within its scope.
Command and Query Separation
A function should either perform some action or answer something. It should not do both.
Either, the function alters the system's state by performing actions or operations, or, retrieve information from the system without altering state.
For example:
public class UserProfile {
private String username;
private int followers;
// Command: Alters state
public void setUsername(String newUsername) {
if (newUsername != null && !newUsername.isEmpty()) {
username = newUsername;
}
}
// Command: Alters state
public void incrementFollowers() {
followers++;
}
// Query: Retrieves information
public String getUsername() {
return username;
}
// Query: Retrieves information
public int getFollowersCount() {
return followers;
}
}
Use Exceptions Instead of Error Codes
Error codes are hard to understand and require additional documentation or lookup to understand their meaning. Prefer Exceptions, to indicate exceptional conditions, errors, or unexpected scenarios.
For Example:
public int readFile(String filePath) {
File file = new File(filePath);
if (!file.exists()) {
return -1; // Error code indicating file not found
}
// Read and process the file
return 0; // Success
}
// VS
public void readFile(String filePath) {
File file = new File(filePath);
if (!file.exists()) {
throw new FileNotFoundException("File not found: " + filePath);
}
// Read and process the file
}
public int validateInput(String input) {
if (input == null || input.isEmpty()) {
return -1; // Error code indicating invalid input
}
// Process valid input
return 0; // Success
}
// VS
public void validateInput(String input) {
if (input == null || input.isEmpty()) {
throw new IllegalArgumentException("Invalid input");
}
// Process valid input
}
public int updateDatabaseRecord(String recordId, String newData) {
if (!databaseConnection.isConnected()) {
return -1; // Error code indicating database not connected
}
// Update the record in the database
return 0; // Success
}
// VS
public void updateDatabaseRecord(String recordId, String newData) {
if (!databaseConnection.isConnected()) {
throw new DatabaseConnectionException("Database not connected");
}
// Update the record in the database
}
You can notice, instead of returning some error code like 0, -1 or 1, We can return exceptions and handle them in the calling function. We can also create our own custom exception handlers for more flexibility.
So, here it ends.
Thank you for your time! Happy Coding!