Making Life Easy with Makefile: Streamlining Software Development for Beginners
Tags: programming, devops, automation, cli
Published: 2023-05-14 16:29:07

Anyone who's spent time in software development knows the tedium of writing and executing repeated commands for testing and building applications. It's a time-consuming process that can feel like running on a hamster wheel. But what if there was a way to streamline this process, automate these repeated tasks, and ultimately make your life as a developer easier? Enter the world of Makefiles.

## What is Makefile? And How Is It Useful?

A Makefile is a text file that contains a list of commands to be executed, along with rules that specify when those commands should be executed. Makefiles are used to automate the process of building software projects. We need to use the command `make <target_name>` to execute our makefile. 


Consider a common scenario in software development: before committing any changes, you want to ensure that all tests pass and that no build failures exist. In such a case, a Makefile can become an invaluable tool. This file should contain all the necessary commands related to testing and building your project.

To automate this process, you simply need to invoke your targets using the `make <target_name>` command within your git hooks. This action triggers the commands specified in the Makefile, executing your tests and checking for build errors.

The beauty of this approach lies in its automation. Instead of manually running tests and checking for build failures every time you commit changes, the process becomes a part of your git workflow, which means it’s going to run your tests and builds every time before committing the changes. This not only makes the development process more efficient, but also ensures consistent quality checks before any changes are committed to your codebase.

## Write your first Makefile
Let's start by writing the classic  'Hello, World!' program.  Begin by creating a new file named Makefile. Inside this file, insert the following code:
```makefile
hello:
	echo "hello world 👋"
```
Now, open your terminal and execute the `make` command. You'll be greeted with the familiar output, as shown in the screenshot below:


![output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lf9m99j21vsd8njskq3i.png)



Congratulation 🎉 you just created your first make file. Now, let's break down what we just did and understand how the syntax of makefile works.

## Understanding Makefile Syntax

In makefile, we have the following syntax structure
```
target: prerequisites
	recipe
```
Let’s see what each of these parts does 

- `target` can be thought of as a task or a goal that you want to accomplish, Typically, a target is the name of a file that is generated by a program, like an executable or object file. However, a target can also be the name of an action to carry out, such as clean. Like in the previous example, `hello` was a target.
- `prerequisites`  this basically says what file if needed to be run before running the `target`
- `recipe` this is the command you want to run
One thing that you must have noticed that whenever you run the target it outputs the `recipe` before running it this gets a bit annoying so to stop this from happening you need to add `@` before the command
```makefile
hello:
	@echo "hello world 👋"
```
 
 > Important: If you simply run make without specifying a target, it defaults to executing the first target listed in the Makefile.

## Let's See This in Action
The best way to solidify our understanding is through practical examples. So, let's apply our knowledge by creating a small Go program. Firstly, make a file and name it `main.go` and add this code inside it
```go
package main

import "fmt"

func add(x, y int) int {
   return x + y
}

func main() {
   fmt.Println(add(1, 2))
}
```
Next, let's write a test to verify if this code functions correctly. Create a new file named `main_test.go` and add the following code: 
```go
package main

import (
   "testing"
)

func TestAdd(t *testing.T) {

   originalOutput := add(1, 2)

   if originalOutput == 3 {
       t.Log("1 + 2 = 3")
   } else {
       t.Fatalf("Expected 3, got %d instead", originalOutput)
   }


}
```

Now, let's automate the building and testing process using a Makefile. Create a new file named `Makefile` and add the following contents:
```makefile
FILENAME=main.go


all: run build test


run:build
	@./bin/main


build:
	@go build -o bin/ $(FILENAME)


test:
	@go test -v ./...


clean:
	@rm -rf bin/


.PHONY: all run build test clean
```
Let's break down how the Makefile works, line by line. The FILENAME variable is used to store the name of our Go file.

- `all`: The default target. It depends on the targets run, build, and test, which will be executed in sequence.
- `run`: The target responsible for running our program. It depends on the build target and executes the ./bin/main command to run the compiled executable.
- `build`: This target compiles our Go code into an executable. The -o bin/ option specifies the output directory for the binary file.
- `test`: The target to run our tests. It executes go test -v ./... to run all tests in the current directory and its subdirectories.
- `clean`: The target to clean up the generated files. It removes the bin/ directory.
- `.PHONY`: This special target declares other targets as phony, meaning they are not actual files but rather commands or actions.
Now you're ready to run `make` in your terminal, and it will automate the build, test, and run processes for your Go program. 

> TIPS: If you wanna change your default target then you have to use `.DEFAULT_GOAL := <target_name>`
Example, if you add `.DEFAULT_GOAL := build` to the makefile we just created and run then command `make` then it’s gonna run `build` and not the `all` target

## Some Advance Stuffs
If you're ready to dive deeper into the world of Makefiles, this section is for you. Here, we'll explore some more advanced techniques and concepts to help you tackle complex scenarios.

### Change Default Shell
To change the default shell used in a Makefile, you can specify the desired shell by setting the SHELL variable. For instance, if you want to use Bash as the default shell, you can define it as follows:

```
SHELL=/bin/bash

myshell:
	echo $0
```
In this example, we set the `SHELL` variable to /bin/bash. Now, when you run the`myshell` target, it will execute the `echo $0` command using Bash as the default shell.

By adjusting the`SHELL` variable, you can customize the shell environment to suit your preferences or meet specific requirements for your Makefile.


### Conditional Statement 
Conditional statements in Makefiles provide a powerful mechanism for executing different commands based on specific conditions. They enable you to tailor the build process according to the environment, variables, or user-defined values. Let's explore an example of how to write conditional statements in a Makefile:
```
foo = ok

all:
ifeq ($(foo), ok)
	echo "foo equals ok"
else
	echo "nope"
endif
```
In this example, we have a variable `foo` with the value ok. The `ifeq` statement checks if the value of $(foo) is equal to ok. If the condition is true, the command echo "foo equals ok" is executed. Otherwise, the command echo "nope" is executed.

When you run make all in the terminal, it will evaluate the conditional statement and execute the appropriate command based on the value of foo. In this case, it will print foo equals ok.
### Functions in Makefile
Functions in Makefiles play a crucial role in writing modular and reusable code. They allow you to encapsulate commands and parameters into named functions, promoting code organization and reducing duplication. Here's how functions are structured in Makefiles:
```
define <function_name>
	# Function body ...
	echo "$(1) $(2) $(3) ..." # Parameters passed
endef

funcCall:
	$(call <function_name>, <param1>, <param2>, ...)

```
Here, we define a user-defined function with the name <function_name> using the `define` directive. The function body consists of the desired commands or operations. Within the function body, you can reference parameters passed to the function using $(1), $(2), $(3), and so on.

To invoke the user-defined function, you use the call directive followed by the function name and its respective parameters.

	
Let's see an example that demonstrates the usage of user-defined functions and their invocation:
```
define foo
	echo "launch 🚀"
endef

define uff
	echo “hey $(1) 👋”
endef

 fooCall:
	@$(call foo)
uffCall:
	@$(call uff, “John Doe”)
```
In this example, we define two user-defined functions: foo and uff. The uff function takes a parameter, which we pass as "John Doe". To call these functions, we use the call directive followed by the function name. Running this code will produce the following output:


![markdown function output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j0jcla3t2nw06l72oxwd.png)



You can also import functions from other makefile using `include` directive, the syntax is `include <fille1> <fille2> <fille3> …`
## Conclusion 
We have learned the power of Makefiles in automating testing and building processes in software development. By utilizing Makefiles, developers can streamline their workflows, save time, and ensure consistent quality checks.

If you want to explore further and dive deeper into the world of Makefiles, I recommend referring to the official GNU Make manual available at https://www.gnu.org/software/make/manual/make.pdf. This comprehensive resource provides in-depth information on Makefile syntax, advanced techniques, and best practices.

Feel free to connect with me on social media [LinkedIn](https://www.linkedin.com/in/sawan-bhattacharya/) to stay updated on the latest software development tips, tricks, and insights.

Happy coding 👨‍💻