DEV Community

Cover image for Making Life Easy with Makefile: Streamlining Software Development for Beginners
Sawan Bhattacharya
Sawan Bhattacharya

Posted on

Making Life Easy with Makefile: Streamlining Software Development for Beginners

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:

hello:
    echo "hello world πŸ‘‹"
Enter fullscreen mode Exit fullscreen mode

Now, open your terminal and execute the make command. You'll be greeted with the familiar output, as shown in the screenshot below:

output

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
Enter fullscreen mode Exit fullscreen mode

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
hello:
    @echo "hello world πŸ‘‹"
Enter fullscreen mode Exit fullscreen mode

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

package main

import "fmt"

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

func main() {
   fmt.Println(add(1, 2))
}
Enter fullscreen mode Exit fullscreen mode

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:

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)
   }


}
Enter fullscreen mode Exit fullscreen mode

Now, let's automate the building and testing process using a Makefile. Create a new file named Makefile and add the following contents:

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

By adjusting theSHELL 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
Enter fullscreen mode Exit fullscreen mode

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>, ...)

Enter fullscreen mode Exit fullscreen mode

Here, we define a user-defined function with the 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”)
Enter fullscreen mode Exit fullscreen mode

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

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 to stay updated on the latest software development tips, tricks, and insights.

Happy coding πŸ‘¨β€πŸ’»

Top comments (0)