Modular Programming in C
— C-programming — 7 min read
Introduction
Today I'm going to demonstrate the concept of modular programming in C. Together, we'll build a simple integer arithmetic calculator. By the end of this, you'll have an idea of how to do modular programming in C with the use of header files, library files, include guards, and Makefile.
Our program will function like this:
1$ ./calculator 1 + 1223$ ./calculator 4 - 2425$ ./calculator 2 '*' 3667$ ./calculator 6 / 283
You can see the full source of the final product here.
What's Modular Programming?
Modular programming is a concept in software design where the program is subdivided into independent modules that are easy to interchange and modify. It's a critical part of making your software easily maintainable, because the last thing you want is a future developer (oftentimes it's you, 6 months from now) cursing your name while trying to decipher a 1000-line main.c
file.
If you want to read more about modular programming, I highly recommend taking a look at the Wikipedia article on modular programming. It's especially important in setting up a robust version control workflow involving atomic commits – they're easy to track, revert, and understand.
In the context of our example, our modules are going to be C files. We'll use features of GCC
and Make to link our modules together to create a single executable.
Step 1: Organization
The first step of programming is to plan out its structure. We'll build our calculator with the following files:
- a
calc.c
file: this will handle commandline arguments that we pass to the executable. - an
operations.c
file: this contains functions implementing the operations our calculator can do. - an
operations.h
file: this contains the function prototypes ofoperations.c
and will be included fromcalc.c
.
Let's create these files in our repo (visible at commit d59597e):
If you examine calc.c
you'll notice I already put a skeleton main()
in the file. All files also have the requisite license boilerplate.
Now let's fill in some of the #include
statements for our .c
files:
1/* calc.c */2#include <stdio.h>3#include "operations.h"4
5int main(int argc, char *argv[])6{7 return 0;8}
1/* operations.c */2#include "operations.h"
Before we try compiling for the first time, we first need to put an include guard in operations.h
:
1/* operations.h */2#ifndef OPERATIONS_H3#define OPERATIONS_H4/* TODO: function prototypes here */5#endif /* OPERATIONS_H */
The need for an include guard is because of how the preprocessor portion of GCC works: it looks for any preprocessor directives (denoted by anything beginning with a #
symbol) and does a text replacement in the C source code for that file as needed before compiling it. If any symbol (variables, functions, etc) is defined more than once in a source file before compilation, GCC will yell at you. The #ifndef OPERATIONS_H
-#define OPERATIONS_H
-#endif
preprocessor directives essentially tells the preprocessor not to include the enclosed sourcecode if OPERATIONS_H
has already been included. This allows both calc.c
and operations.c
to include operations.h
without any errors or warnings being thrown by GCC when we compile it all with:
1$ gcc -c calc.c operations.c2$ gcc -o calculator calc.o operations.o
You can read more about include guards here.
At this point, we can make a new commit (see 77bf9b6).
Step 2: Automating the Build
You might have noticed that the GCC commands above are a bit verbose to run each time. Let's automate that with a Makefile. I won't go into too much detail about what's going on here because that deserves its own post, but the tl;dr is that Make looks at a (carefully crafted) Makefile to determine the minimum compilation commands required for the executable to reflect changes in source code. I highly recommend looking at the O'Reilly book on Make, available for free here.
Here's the Makefile:
1CC = /usr/bin/gcc2CFLAGS = -Wextra -Wpedantic3
4# Project files5SRCS = calc.c operations.c6OBJS = $(SRCS:.c=.o)7EXE = calculator8DEPS = %.h9
10.PHONY: clean11
12###############################################################################13# Release rules #14###############################################################################15# Create final executable16$(EXE): $(OBJS)17 $(CC) $(CFLAGS) -o $@ $^18
19# Compile and assemble source into object files20%.o: %.c $(DEPS)21 $(CC) -c $(CFLAGS) -o $@ $<22
23# Clean rules24clean:25 rm -f $(EXE) $(OBJS)
Now we can just run make
to compile the executable each time:
1$ make2/usr/bin/gcc -c -Wextra -Wpedantic -o operations.o operations.c3operations.c:24:24: warning: ISO C requires a translation unit to contain at4 least one declaration [-Wempty-translation-unit]5#include "operations.h"6 ^71 warning generated.8/usr/bin/gcc -Wextra -Wpedantic -o calculator calc.o operations.o
We're getting some warnings because I've chosen to include the GCC flags -Wextra
and -Wpedantic
. Generally it's good practice to compile with these flags because they'll warn you when you try to compile things like a source file with no declarations, as shown above.
I can also run make clean
to run the clean
rule defined in Makefile
:
1$ make clean2rm -f calculator calc.o operations.o
You can browse the state of the repository at this step here.
Step 3: Automating Tests
In the Introduction, we described what functions calculator
will have and the outputs we expect. We can automate the process of testing each function in a simple shellscript to save us some time.
1# test.sh2#!/usr/bin/env bash3
4echo "test: ./calculator 1 + 1"5./calculator 1 + 16
7echo "test: ./calculator 4 - 2"8./calculator 4 - 29
10echo "test: ./calculator 2 '*' 3"11./calculator 2 '*' 312
13echo "test: ./calculator 6 / 2"14./calculator 6 / 2
note that the * symbol has to be single-quoted to prevent shell expansion
You can browse the state of the repository at this step here.
Step 4: (Finally) Implementing Features
Now we can finally write some C code! Let's first declare our functions in operations.h
:
1/* operations.h */2#ifndef OPERATIONS_H3#define OPERATIONS_H4
5int add(int x, int y);6
7int subtract(int x, int y);8
9int multiply(int x, int y);10
11int divide(int x, int y);12#endif /* OPERATIONS_H */
Now let's implement them in operations.c
:
1/* operations.c */2#include "operations.h"3
4/* Returns the int result of x + y where x and y are type int */5int add(int x, int y)6{7 return x + y;8} /* add() */9
10/* Returns the int result of x - y where x and y are type int */11int subtract(int x, int y)12{13 return x - y;14} /* subtract() */15
16/* Returns the int result of x * y where x and y are type int */17int multiply(int x, int y)18{19 return x * y;20} /* multiply() */21
22/* Returns the int result of x / y where x and y are type int */23int divide(int x, int y)24{25 return x / y;26} /* divide() */
At this point, we only have to deal with the commandline arguments in calc.c
and then we're done! Here's calc.c
:
1/* calc.c */2#include <stdio.h>3#include <stdlib.h>4#include "operations.h"5
6int main(int argc, char *argv[])7{8 int num1;9 char operation;10 int num2;11 int result;12
13 if (argc != 4)14 return EXIT_FAILURE;15 num1 = atoi(argv[1]);16 operation = argv[2][0];17 num2 = atoi(argv[3]);18
19 switch (operation) {20 case '+':21 result = add(num1, num2);22 break;23 case '-':24 result = subtract(num1, num2);25 break;26 case '*':27 result = multiply(num1, num2);28 break;29 case '/':30 result = divide(num1, num2);31 break;32 }33
34 printf("%d\n", result);35
36 return EXIT_SUCCESS;37}
Let's make
the program and test it:
1$ make2/usr/bin/gcc -Wextra -Wpedantic -c -o calc.o calc.c3/usr/bin/gcc -c -Wextra -Wpedantic -o operations.o operations.c4/usr/bin/gcc -Wextra -Wpedantic -o calculator calc.o operations.o5$ ./test.sh6test: ./calculator 1 + 1728test: ./calculator 4 - 29210test: ./calculator 2 '*' 311612test: ./calculator 6 / 2133
Looks good!
You can browse the state of the repository at this step here.
Wrap-Up
This program demonstrates modular programming using C header files, #ifndef OPERATIONS_H
-#define OPERATIONS_H
-#endif
preprocessor directives, and Make. It's by no means the most robust way of implmenting an integer calculator – for example, it assumes input is formatted correctly and doesn't handle integers beyond the width of int
. Perhaps most glaringly, it is overengineered since an integer calculator can easily be implemented in a single-file C program. However, by now you should have a good grasp of modular programming in C.
The repository in its final state can be viewed here.
Questions? Comments? Write to me at [email protected].