Module 1: Functions and Metaprogramming

Functions in Make

Learning objectives

  • Explain the core mental model behind Functions in Make
  • Apply Functions in Make within Functions and Metaprogramming
  • Identify important boundaries, trade-offs, and failure modes
  • Produce concrete evidence from the practice exercise

Related: Variables | Pattern Rules | Conditionals | Make Index


Why Functions Exist in Make

Variables store values, but often you need to transform values — change extensions, filter a list, search for files, or compute a string. Make's built-in functions handle these transformations without shelling out:

SRCS := main.c utils.c parser.c

# Without functions — you'd have to list objects manually
OBJS := main.o utils.o parser.o

# With patsubst — derived automatically
OBJS := $(patsubst %.c,%.o,$(SRCS))
# Or the shorthand substitution reference:
OBJS := $(SRCS:.c=.o)

Functions also let you query the filesystem, manipulate paths, and write conditional logic — turning Make into a real build language rather than just a rule executor.


Function Call Syntax

$(function-name arg1,arg2,arg3)
${function-name arg1,arg2,arg3}   # alternative delimiters — less common

Arguments are separated by commas. Spaces around commas are included in the argument (significant!):

$(subst a, b,text)     # arg1 = "a", arg2 = " b", arg3 = "text" — space included!
$(subst a,b,text)      # arg1 = "a", arg2 = "b", arg3 = "text" — correct

String Substitution Functions

$(subst from,to,text)

Simple find-and-replace on literal strings:

result := $(subst .c,.o,main.c utils.c)
# result = main.o utils.o

result := $(subst cc,xx,$(CC))
# if CC = gcc → result = gxx

$(patsubst pattern,replacement,text)

Pattern-based substitution using % as a wildcard for the stem:

SRCS := src/main.c src/utils.c
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
# OBJS = build/main.o build/utils.o

# Shorthand substitution reference (equivalent to patsubst %.c,%.o):
OBJS := $(SRCS:.c=.o)

$(strip text)

Removes leading and trailing whitespace, and collapses internal whitespace to single spaces:

X := "  hello   world  "
Y := $(strip $(X))
# Y = "hello world"

$(findstring find,text)

Returns find if it occurs in text, empty string otherwise:

has_debug := $(findstring debug,$(CFLAGS))
ifeq ($(has_debug),debug)
    $(info Debug flags detected)
endif

List and Word Functions

Make treats whitespace-separated words as lists. These functions operate on such lists.

$(filter pattern...,text)

Keep only words that match any of the patterns:

INPUT  := foo.c bar.h baz.c qux.s
C_FILES := $(filter %.c,$(INPUT))
# C_FILES = foo.c baz.c

# Multiple patterns
C_AND_H := $(filter %.c %.h,$(INPUT))
# C_AND_H = foo.c bar.h baz.c

$(filter-out pattern...,text)

Remove words that match — the complement of filter:

INPUT := main.o test.o debug.o release.o
NO_TEST := $(filter-out test.o debug.o,$(INPUT))
# NO_TEST = main.o release.o

$(sort list)

Sorts words alphabetically and removes duplicates:

X := beta alpha gamma alpha
Y := $(sort $(X))
# Y = alpha beta gamma

$(word n,text)

Returns the nth word (1-indexed):

X := one two three four
Y := $(word 2,$(X))    # Y = two

$(words text)

Returns the number of words:

X    := a b c d
N    := $(words $(X))    # N = 4
LAST := $(word $(N),$(X)) # LAST = d

$(firstword text) / $(lastword text)

X := alpha beta gamma
$(firstword $(X))    # alpha
$(lastword $(X))     # gamma

$(wordlist start,end,text)

Returns words from start to end (1-indexed, inclusive):

X := a b c d e f
Y := $(wordlist 2,4,$(X))    # Y = b c d

Path/Filename Functions

$(dir names)

Returns the directory part (with trailing /) of each path:

X := src/foo.c include/bar.h
Y := $(dir $(X))    # Y = src/ include/

$(notdir names)

Returns the filename without the directory:

X := src/foo.c include/bar.h
Y := $(notdir $(X))    # Y = foo.c bar.h

$(suffix names)

Returns the file extension (including .):

X := main.c utils.o build.mk
Y := $(suffix $(X))    # Y = .c .o .mk

$(basename names)

Returns the filename without the extension:

X := src/main.c src/utils.o
Y := $(basename $(X))    # Y = src/main src/utils

$(addsuffix suffix,names)

Appends a suffix to each word:

NAMES := main utils parser
SRCS  := $(addsuffix .c,$(NAMES))    # SRCS = main.c utils.c parser.c

$(addprefix prefix,names)

Prepends a prefix to each word:

NAMES  := main.o utils.o
BUILT  := $(addprefix build/,$(NAMES))    # BUILT = build/main.o build/utils.o

$(join list1,list2)

Pairwise concatenation of two lists:

X := a b c
Y := 1 2 3
Z := $(join $(X),$(Y))    # Z = a1 b2 c3

$(realpath names) / $(abspath names)

Convert paths to absolute paths. realpath resolves symlinks; abspath does not:

ABS := $(abspath ../include)    # /home/user/project/include

Filesystem Function

$(wildcard pattern)

Expands a glob pattern against the actual filesystem (unlike % in rules, which Make expands):

SRCS := $(wildcard src/*.c)
# SRCS = src/main.c src/utils.c src/parser.c  (whatever exists)

ALL_HEADERS := $(wildcard include/**/*.h)    # recursive glob (GNU Make 4.3+)

Critical difference: % in rules matches file names during rule lookup; $(wildcard ...) actually queries the filesystem at Makefile parse time.


Control Flow Functions

$(foreach var,list,text)

Iterates over a list, expanding text with var set to each word:

DIRS := src lib test
# Create mkdir commands for each
$(foreach d,$(DIRS),mkdir -p $(d);)
# Expands to: mkdir -p src; mkdir -p lib; mkdir -p test;

# More typical use — build a list
OBJS := $(foreach d,$(DIRS),$(wildcard $(d)/*.o))

$(if condition,then[,else])

# If VERBOSE is non-empty, use verbose flag; else silent
VFLAG := $(if $(VERBOSE),-v,)

# Nested if
MODE := $(if $(DEBUG),debug,$(if $(RELEASE),release,dev))

$(or cond1[,cond2,...])

Returns the first non-empty argument:

CC := $(or $(CC),$(shell which clang),gcc)
# Use CC if set, else find clang, else fall back to gcc

$(and cond1[,cond2,...])

Returns the last argument if all are non-empty; otherwise empty:

# Only set EXTRA if both DEBUG and VERBOSE are set
EXTRA := $(and $(DEBUG),$(VERBOSE),-DDEBUG_VERBOSE)

Shell Function

$(shell command)

Runs a shell command and returns its stdout (newlines replaced with spaces):

GIT_HASH := $(shell git rev-parse --short HEAD)
DATE     := $(shell date +%Y%m%d)
NPROC    := $(shell nproc)

$(info Building version $(GIT_HASH) on $(DATE))

# Use in a rule:
version.h:
	echo "#define GIT_HASH \"$(GIT_HASH)\"" > $@

Warning: $(shell ...) runs at parse time (when the variable is first evaluated), not when the rule runs. Side effects execute during Makefile loading.


$(call) — User-Defined Functions

Use define to create a named macro and $(call ...) to invoke it:

# Define a function: $(1), $(2), ... are positional arguments
define compile_c
$(CC) $(CFLAGS) -c -o $(1) $(2)
endef

# Call it
main.o: main.c
	$(call compile_c,$@,$<)

# More practical: a function that builds a target+dep list
define make_obj
$(patsubst %.c,%.o,$(1))
endef

OBJS := $(call make_obj,$(SRCS))

$(value) — Raw Variable Value

Returns a variable's value before recursive expansion — useful for debugging:

X = hello $(Y)
$(info value of X: $(value X))   # prints: hello $(Y)   (not expanded)
$(info X:          $(X))          # prints: hello world  (fully expanded)

$(eval) — Dynamic Rule Generation

$(eval ...) parses its argument as Makefile syntax at runtime. This enables dynamic rule generation:

define COMPILE_RULE
$(1).o: $(1).c
	$(CC) -c -o $$@ $$<
endef

MODULES := main utils parser
$(foreach m,$(MODULES),$(eval $(call COMPILE_RULE,$(m))))
# Generates explicit rules for main.o, utils.o, parser.o

Note the $$@$ must be doubled inside eval because it's expanded twice.


$(info), $(warning), $(error) — Diagnostic Output

$(info   This is informational — printed, build continues)
$(warning Watch out — printed with filename:line, build continues)
$(error  Fatal — printed with filename:line, build STOPS immediately)

# Useful for debugging variables:
$(info SRCS = $(SRCS))
$(info OBJS = $(OBJS))

# Guard against missing required variables:
ifndef API_KEY
$(error API_KEY is not set. Run: export API_KEY=...)
endif

Common Pitfalls

# PITFALL 1: Comma in argument — use a variable
COMMA := ,
result := $(subst $(COMMA), ,a,b,c)   # replaces commas with spaces
# Can't use literal comma inside $(subst ) args without this trick

# PITFALL 2: $(wildcard) returns empty if nothing matches — no error
SRCS := $(wildcard *.nonexistent)   # SRCS = "" — silent!
# Guard: ifeq ($(SRCS),) $(error No source files found) endif

# PITFALL 3: $(shell) side effects at parse time
RESULT := $(shell rm -f stale.o && echo removed)   # runs immediately!

# PITFALL 4: Spaces in $(foreach) separator
X := $(foreach d,a b c,$(d)/)    # X = a/ b/ c/    (spaces between)
# This is fine for lists, but be careful when using as shell args

Practice lab

Create a small repository that exercises Functions in Make. Capture a clean build, an incremental no-op build, and a deliberately broken dependency case; explain every command Make chooses to run. Add an operational constraint such as concurrency, recovery, security, latency, or cost, and defend the resulting design trade-off.

Review questions

  1. What problem does Functions in Make solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. What artifact, trace, test, or metric proves that your implementation is correct?

Completion evidence

  • A working artifact, annotated trace, or reproducible experiment
  • At least one normal case and one deliberately failing or boundary case
  • A concise explanation of the design choice and its trade-offs
  • Saved output showing how correctness was evaluated