These signs can be spotted giving a heads-up to show where the code is broken or inefficient so as to enable timely improvisations.
In today's article we will do an in-depth study of Code Smells. We will discuss the types of code smells, their identification, and, most importantly, why they matter and how to spot them easily.
Code smells are indicators that, when detected, surface critical loopholes, missing commands, or issues in the respective application or codebase. Sometimes, these can also predict errors or issues that might arise in the near future, even if there aren't any currently. These errors can be modified during refactoring as the code smells are easy to detect and fix.
Code smells may also manifest as symptoms of deeper issues within the code. So, even if the code is performing and operating as intended, it may interfere with the development process and pose security or implementation threats while the program or application is running.
Therefore, by definition, a code smell represents an indication of a problem simmering beneath the surface currently or that might originate in the near future. Why the word 'smell'? Simply because it is easy to spot!
Code smells originate when code created by developers does not meet the required design standards or principles. The reasons behind this can be varied, including lack of understanding or knowledge, time constraints, rushed efforts to meet deadlines, and at times, bypassing code reviews, which can lead to mistakes causing these glitches during development and resulting in code smells.
Code smells come in different kinds and vary based on the project as well as the developer. Here are some of the most common types:
In the example below, the function aims to calculate the sum of even numbers from a given array. However, it is unnecessarily complex and could be simplified using higher-order functions in Swift.
func findSumOfEvenNumbers(numbers: [Int]) -> Int {
var sum = 0
for number in numbers {
if number % 2 == 0 {
sum += number
}
}
return sum
}
The refactored version is much more concise and easier to understand. It utilizes the filter method to filter out even numbers and the reduce method to calculate their sum. This effectively removes the contrived complexity present in the original implementation.
func findSumOfEvenNumbers(numbers: [Int]) -> Int {
let evenNumbers = numbers.filter { $0 % 2 == 0 }
return evenNumbers.reduce(0, +)
}
For example, in the code below; it is unclear what is happening and what these variables are for:
1// user data
2int a;
3String n;
4String e;
Now, it is easier to understand the meaning with better variable names:
1// user data
2int age;
3String name;
4String email;
In the example below, MiddleMan class has two methods, both of which simply delegate functionality to the Data Manager class. The MiddleMan class does not provide any additional value or functionality. It merely acts as an unnecessary middleman between the client code and the Data Manager.
1class MiddleMan {
2 private var dataManager: DataManager
3
4 init() {
5 self.dataManager = DataManager()
6 }
7
8 func fetchData() -> [String] {
9 return dataManager.fetchData()
10 }
11
12 func saveData(_ data: [String]) {
13 dataManager.saveData(data)
14 }
15}
As a result, the MiddleMan Class can be deleted, and the Data Manager class can be used straight away in the client code. This will be beneficial as it will streamline the codebase and also increase the organized efforts.
In the snippet below, the function prints a message but hardcodes the version number into the message. If you need to update the app to a newer version, you would have to modify the function's code directly, which is not ideal.
1func printWelcomeMessage() {
2 print("Welcome to our app version 1.0!")
3}
To avoid this, we should pass the version number as a parameter in the function. Now the function can accept different version numbers without modifying its code.
1func printWelcomeMessage(version: String) {
2 print("Welcome to our app version \(version)!")
3}
Like in below example the class has no properties of its own and hence can be recognized as LazyClass. And since it is not adding any value at all to the codebase, it should be removed.
1class LazyClass {
2 // This class has no methods or properties, just an empty shell.
3}
In the example stated below, the subclass Fish does not utilize the behavior it has inherited from its superclass - Animal. This leads to code smell. As a resolution, it is to be ensured that all subclasses use the inherited methods correctly or override it completely.
1class Animal {
2 func makeSound() {
3 // Code to make a generic animal sound
4 print("Animal makes a sound.")
5 }
6}
7
8class Cat: Animal {
9 override func makeSound() {
10 // Code to make a specific sound for a cat
11 print("Meow!")
12 }
13}
14
15class Fish: Animal {
16 // Fish does not override the makeSound() method
17 // Refusing to use the inherited behavior
18}
19
20// Usage
21let cat = Cat()
22cat.makeSound() // Output: "Meow!"
23
24let fish = Fish()
25fish.makeSound()
Also read: Simplifying software development by building faster with reusable components
In the below example, the GodObject class has properties to store data related to customers, orders, and inventory. It contains various methods to process and manipulate this data.
1class GodObject {
2 var customerData: [Customer]
3 var orderData: [Order]
4 var inventoryData: [Product]
5
6 init() {
7 customerData = []
8 orderData = []
9 inventoryData = []
10 }
11
12 func processCustomerData() {
13 // Complex logic to process customer data
14 // ...
15 }
16
17 func processOrderData() {
18 // Complex logic to process order data
19 // ...
20 }
21
22 func updateInventory() {
23 // Complex logic to update product inventory data
24 // ...
25 }
26
27 // ... many more methods and properties handling various functionalities ...
28}
To address the code smell in the above case, split the class responsibilities.
Like in the example below, a single function is using multiple if-else statements to manage different commands.
1func greetUser(timeOfDay: String) {
2 if timeOfDay == "Morning" {
3 print("Good morning!")
4 } else if timeOfDay == "Afternoon" {
5 print("Good afternoon!")
6 } else if timeOfDay == "Evening" {
7 print("Good evening!")
8 } else {
9 print("Hello!")
10 }
11}
Hence, one can use a dictionary to solve this since that is a cleaner approach than if-else statements.
1func greetUser(timeOfDay: String) {
2 let greetings = [
3 "Morning": "Good morning!",
4 "Afternoon": "Good afternoon!",
5 "Evening": "Good evening!"
6 ]
7
8 if let greeting = greetings[timeOfDay] {
9 print(greeting)
10 } else {
11 print("Hello!")
12 }
13}
Also read: Guide to Incorporating Augmented Reality into Flutter Apps
In the below example “createproduct” method has a long list of parameters. This makes the function call cumbersome and hard to read.
1class ProductService {
2 func createProduct(productId: Int, name: String, category: String, price: Double, manufacturer: String, stockQuantity: Int, isOnSale: Bool, salePrice: Double?) {
3 // Code to create a new product with the provided parameters.
4 // ...
5 }
6}
To address this smell, we can use “struct” or “class” to encapsulate multiple parameter lists into a single entity.
1struct Product {
2 let productId: Int
3 let name: String
4 let category: String
5 let price: Double
6 let manufacturer: String
7 let stockQuantity: Int
8 let isOnSale: Bool
9 let salePrice: Double?
10 }
11
12class ProductService {
13 func createProduct(product: Product) {
14 // Code to create a new product using the provided Product instance.
15 // ...
16 }
17
18 // Other methods related to product management...
19}
Here in the below example, person.address.city creates message chains of method calls.
1class Person {
2 var name: String
3 var address: Address
4
5 init(name: String, address: Address) {
6 self.name = name
7 self.address = address
8 }
9}
10
11class Address {
12 var city: String
13 var postalCode: String
14
15 init(city: String, postalCode: String) {
16 self.city = city
17 self.postalCode = postalCode
18 }
19}
20
21let person = Person(name: "Colin Hoppe", address: Address(city: "New York", postalCode: "411057"))
22
23let city = person.address.city
Now, the Person class has a getCity() method that retrieves the city from the Address object. This decouples the code from the internal structure of the Address class.
1class Person {
2 var name: String
3 private var address: Address
4
5 init(name: String, address: Address) {
6 self.name = name
7 self.address = address
8 }
9
10 func getCity() -> String {
11 return address.city
12 }
13}
14
15class Address {
16 var city: String
17 var postalCode: String
18
19 init(city: String, postalCode: String) {
20 self.city = city
21 self.postalCode = postalCode
22 }
23}
24
25let person = Person(name: "Colin Hoppe", address: Address(city: "New York", postalCode: "411057"))
26
27let city = person.getCity()
The below example showcases a calculation where the sum of an integer array is deduced using a loop and indexing. Even though the code works, it also shows an oddball solution smell and thus it cannot be used as a solution for Swift.
1func calculateSum(_ numbers: [Int]) -> Int {
2 var sum = 0
3 for i in 0 ..< numbers.count {
4 sum += numbers[i]
5 }
6 return sum
7}
There’s a method to sum an array of integers in Swift. By using the reduce method, the calculation can be completed. This technique is a higher-order function as it will keep on repeating over the elements of the array.
func calculateSum(_ numbers: [Int]) -> Int {
return numbers.reduce(0, +)
}
Below methods are generic and do not hold any specific relation to any actual data type currently being handled:
1class DataManager {
2 func fetchDataFromAPI(url: String, parameters: [String: Any]) {
3 // Code to fetch data from the API using the given URL and parameters
4 }
5
6 func saveDataToDatabase(data: [String: Any]) {
7 // Code to save data to the database
8 }
9
10 // More generic methods...
11}
1struct User {
2 var id: Int
3 var name: String
4 var email: String
5 // Additional properties specific to the User entity
6}
7
8class UserDataManager {
9 func fetchUsersFromAPI() -> [User] {
10 // Code to fetch user data from the API
11 return [] // For simplicity, returning an empty array here
12 }
13
14 func saveUserToDatabase(_ user: User) {
15 // Code to save user data to the database
16 }
17
18 // Other user-specific methods...
19}
20
21// Usage
22let userDataManager = UserDataManager()
23let users = userDataManager.fetchUsersFromAPI()
In the above example, the Speculative Generality has been removed during refactoring. A specific UserDataManager class was used for user-related operations. Now, suppose if we have to handle a variety of data, we can create separate managers or classes for respective data types instead of having overly generic methods.
Also read: [Comparison] Android UI Development with Jetpack Compose vs XML
In the below example, Using primitives (Double) for such domain concepts can be a sign of Primitive Obsession, especially if we have multiple functions repeating this pattern or if the concept of a rectangle is used in various places throughout the codebase.
1func calculateAreaOfRectangle(width: Double, height: Double) -> Double {
2 let area = width * height
3 return area
4}
5
6let width = 5.0
7let height = 10.0
8let area = calculateAreaOfRectangle(width: width, height: height)
Here in the below code, by using a custom struct, we address the Primitive Obsession code smell and provide a clearer representation of the rectangle domain concept throughout the codebase. If we need to add more behaviors or properties related to rectangles, we can easily do so within the struct.
1struct Rectangle {
2 var width: Double
3 var height: Double
4
5 func calculateArea() -> Double {
6 return width * height
7 }
8}
9
10let rectangle = Rectangle(width: 5.0, height: 10.0)
11let area = rectangle.calculateArea()
1func processOrder(orderId: Int, customerName: String, customerEmail: String, shippingAddress: String, items: [Product]) {
2 // Code to process the order
3}
4
5// Usage
6let orderId = 12345
7let customerName = "Stephen"
8let customerEmail = "Stephen@gmail.com"
9let shippingAddress = "123, Main Street"
10let items = [Product(name: "Widget", price: 10.0), Product(name: "Gadget", price: 20.0)]
11
12processOrder(orderId: orderId, customerName: customerName, customerEmail: customerEmail, shippingAddress: shippingAddress, items: items)
In the above example, the processOrder takes various parameters under consideration. These factors frequently appear together even when a command happens from multiple places. By grouping related data together using the data structure, we can address the situation of data clump.
1struct Order {
2 var orderId: Int
3 var customerName: String
4 var customerEmail: String
5 var shippingAddress: String
6 var items: [Product]
7}
8
9func processOrder(order: Order) {
10 // Code to process the order
11}
12
13// Usage
14let order = Order(orderId: 12345, customerName: "Stephen", customerEmail: "Stephen@gmail.com", shippingAddress: "123, Main Street", items: [Product(name: "Widget", price: 10.0), Product(name: "Gadget", price: 20.0)])
15
16processOrder(order: order)
When the switch happens because of an object type, a design pattern is used to frame and outline it for smoother functioning.
1func getDayName(_ day: Int) -> String {
2 switch day {
3 case 1:
4 return "Sunday"
5 case 2:
6 return "Monday"
7 case 3:
8 return "Tuesday"
9 case 4:
10 return "Wednesday"
11 case 5:
12 return "Thursday"
13 case 6:
14 return "Friday"
15 case 7:
16 return "Saturday"
17 default:
18 return "Invalid day"
19 }
20}
The above can be considered as a code smell because here we are using magic numbers. Also, this code has limited flexibility because if we need to extend a function, it would add additional complexity.
We should use enum instead to avoid this smell.
1enum DayOfWeek: Int {
2 case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
3
4 var name: String {
5 switch self {
6 case .sunday:
7 return "Sunday"
8 case .monday:
9 return "Monday"
10 case .tuesday:
11 return "Tuesday"
12 case .wednesday:
13 return "Wednesday"
14 case .thursday:
15 return "Thursday"
16 case .friday:
17 return "Friday"
18 case .saturday:
19 return "Saturday"
20 }
21 }
22}
23
24func getDayName(_ day: DayOfWeek) -> String {
25 return day.name
26}
In the snippet below, it is not apparent why we are checking 10. What is the significance behind this number?
1func calculateAreaOfCircle(radius: Double) -> Double {
2 let pi = 3.14159
3 return pi * radius * radius
4}
5
6let radius = 5.0
7let area = calculateAreaOfCircle(radius: radius)
In the above example, the value of pi is hard coded as 3.14159. While it's a valid value for π (pi), it is considered a magic number because its meaning is not immediately apparent to someone reading the code. Instead, it is better to use the built-in constant Double.pi or declare a named constant for it.
1func calculateAreaOfCircle(radius: Double) -> Double {
2 let pi = Double.pi
3 return pi * radius * radius
4}
5
6let radius = 5.0
7let area = calculateAreaOfCircle(radius: radius)
Also read: Creating Private CocoaPods Libraries
Once the concept of code smells is well understood, it becomes easy to handle them. The removal and tackling of code smells happen through refactoring. Refactoring can be seen as a complete makeover of the code, which is complete with sweeping, decluttering, and streamlining.
Recognizing the need for refactoring
Spotting a code smell is a major task. Once that is done, look at the indicator and understand the location of the code smell. So the initial step becomes recognition. The acknowledgment of code smells' existence marks a major work done.
Understand the code smell
Once the code smell has been identified, another milestone to cover is recognizing its category or belonging. Maybe it is a duplication or a piece of code, which is kept for future use. Maybe there is a function that is too large. Once we know exactly what we are dealing with, it becomes easy to refactor it efficiently.
Plan your approach
Refactoring is not a process that can be completed hurriedly. It requires a methodological approach that demands thought and deliberation. Before taking any step, plan ahead - break down the problem into doable tasks, outline your goals, and find out the best way to solve the issue behind the code smell.
Make small, incremental changes
Refactoring does not necessarily mean that your entire code is to be written again. It only means that once the issues have been identified, changes need to be made to the code. These changes are localized to the origin of the code smell and are manageable. This approach reduces the risk of bugs and also maintains full functionality throughout the process.
Utilize design patterns
Design patterns serve as tested templates for perfect code. They offer varied solutions to common problems, helping in the process of refactoring. Whether it is the Singleton pattern, Factory pattern, or Observer pattern, implementing these patterns can prove extremely advantageous in removing code smells.
Leverage automated tests
Refactoring the first stage is always followed by testing. Anything that goes without testing cannot guarantee accuracy. Automated tests guarantee safety and lower the amount of risks. They also ensure that your changes don't break current functionality. Hence, before beginning the refactoring activity, it is to be ensured that testing mechanisms and plans are active.
Iterate and review
Refactoring is not a one-step process. It happens in iteration. After introducing any change, review the code. Test its functionality and check for optimization. One can always take a second opinion to review the code as well and ensure its functionality.
Document your changes
It is important that documentation of every step is done with clarity and precision. During the refactoring process, put in comments wherever necessary to showcase the changes. This leads to clear understanding as well as make it easy to collaborate.
Measure the impact
Once refactoring is completed, evaluation must begin. It will help in gaining insights into the impact of changes made. One can understand if the code smells have been removed or not. It will also show if the code is now clean and easy to manage. Performance improvements and bug reduction can also be observed.
Here, we are sharing some tips that new developers can follow to avoid code smells. If one follows these tips, the code can remain clean throughout, and the chances of code smell arising will be low.
Creating clean code takes time. To master this subject, always keep learning and practicing.
Also read: [Guide] Creating Charts with Swift Charts
In this blog, we have learned about the importance of code smells in detail and discovered their inevitability during the course of software development. We have also gained an understanding of the various types of code smells and their impact.
It is important to note that code smells serve as early indicators of potential problems, and all these issues can be easily solved through refactoring. If we address them quickly and also utilize best practices, then we can make our code strong, bug-free, and adaptable over time.
At Aubergine Solutions, we understand and reaffirm the importance of clean code and efficient solutions. Our skilled developers create top-notch software that is designed to meet your current requirements and remain effective over time. Connect with us today to find out more about our software development services and how we can collaborate with you on your next project.If you have also witnessed your code not feeling as fresh as it should have been, or if you have doubts about any potential glitches or pop-ups, then you are definitely not alone! However, you are well aware of this and have already entered the world of 'Code Smells.' These are the little tell-tale signs of what might be wrong with your code and what could be done better or paid more attention to.