SECTION 1
Historical Context & Motivation
The practice of refactoring — restructuring existing code without changing its external behavior — emerged alongside the recognition that software is not written once and forgotten but continuously read, modified, and extended. In procedural and scripting languages such as R, the most pervasive form of technical debt is copy-paste duplication: identical or near-identical blocks of code scattered across a script, each performing the same computation with slightly different inputs. When a bug is discovered in one copy, every other copy must be located and patched — a fragile, error-prone process that violates the Don't Repeat Yourself (DRY) principle. Understanding the historical arc of refactoring helps explain why extracting repeated code into functions became a cornerstone of professional software development.
1972 • Structured Programming
1993 • R Language Created
1999 • Fowler's Refactoring Catalog
2014 • Tidyverse & Functional R
Despite these decades of advocacy, R scripts in academic and industry settings still routinely contain dozens of copy-pasted code blocks differentiated only by a variable name or file path. The central question this lesson addresses is straightforward yet powerful: How do we identify repeated patterns in R code and systematically extract them into well-named, parameterized functions?
SECTION 2
Core Principles of Function Extraction
Before writing a single line of refactored code, it is important to internalize the guiding principles that justify the effort. Extracting duplicated code into functions is not merely an aesthetic preference; it is grounded in well-established software engineering axioms that reduce defect rates, lower maintenance costs, and improve team collaboration. The following foundational ideas serve as the intellectual scaffolding for every refactoring decision you will make in R.
DRY — Don't Repeat Yourself
Single Responsibility
Abstraction via Parameterization
Behavior Preservation
Naming as Documentation
normalize_column() is self-documenting in a way that five lines of arithmetic are not.KEY TAKEAWAY
SECTION 3
Visual Explanation — Before and After Extraction
The following diagram contrasts a typical R script containing three duplicated blocks of normalization logic (left) with the refactored version that extracts those blocks into a single reusable function (right). Notice how the duplicated code — shaded in red — collapses into a single function definition, and each call site is replaced by a concise one-liner. The arrows trace the data flow from raw input through the parameterized function to the normalized output.
normalize() function defined once and called three times. The extraction reduces lines of code and, more critically, ensures any fix to the normalization logic propagates everywhere automatically.The diagram makes visible a pattern that is easy to overlook when scrolling through a long script: the invariant logic (computing mean, standard deviation, and the z-score) is identical across all three blocks, while the only variant element is the input vector. The extraction step promotes that variant to a parameter (x) and wraps the invariant logic in a named function. This simple mechanical transformation is the essence of the Extract Function refactoring.
SECTION 4
The Mechanics of Function Extraction in R
R's status as a functional programming language makes function extraction particularly natural. Functions in R are first-class objects: they can be assigned to variables, passed as arguments to other functions, and returned from functions. The basic syntax for defining a function is function_name <- function(param1, param2, ...) { body }. The body can contain any valid R expressions, and the last evaluated expression is implicitly returned (though an explicit return() call is also acceptable). Understanding the mechanics of scoping, default arguments, and side-effect management is essential to performing safe extractions.
Step-by-Step Extraction Protocol
- Identify the duplication. Scan for blocks that share the same structural pattern — same operations in the same order, differing only in one or two values.
- Catalog the variant parts. List every element that changes across copies. These become function parameters.
- Write the function signature. Choose a descriptive verb-noun name (e.g.,
compute_z_scores) and declare parameters with sensible defaults where appropriate. - Move the invariant logic into the function body. Replace the hardcoded varying values with the parameter names.
- Replace each original block with a function call. Pass the original varying values as arguments.
- Verify behavior preservation. Compare outputs before and after using
all.equal()or a testing framework such astestthat.
Scoping & Side Effects
R uses lexical scoping: a function looks up free variables in the environment where it was defined, not where it is called. This means that if your duplicated block references a global variable without passing it as a parameter, the extracted function will still find it — but this creates a hidden dependency that makes the function fragile and harder to test in isolation. Best practice is to make every input explicit through parameters and to avoid modifying objects outside the function's own environment (i.e., avoid the <<- operator unless absolutely necessary). A pure function — one that depends only on its inputs and produces no side effects — is the easiest to reason about, test, and reuse.
Default Arguments as Documentation
normalize <- function(x, na.rm = TRUE) communicates that missing-value removal is the expected norm, while still allowing callers to override it. Defaults reduce cognitive load at call sites and serve as inline documentation of the function's intended usage.SECTION 5
Common Duplication Patterns in R
Not all duplication looks the same. In R programming — especially in data analysis and statistical modeling — repeated code manifests in several recurring patterns. Recognizing these patterns quickly is a skill that separates novice scripts from production-quality code. The following diagram classifies the four most common duplication patterns encountered in R, along with the corresponding extraction strategy for each.
Pattern 1 (column-wise repetition) is the most frequent offender in exploratory data analysis: the same summary statistics computed for every numeric column. Pattern 2 (file-processing repetition) arises when multiple CSV or Excel files must be ingested with the same cleaning steps. Pattern 3 (plot-generation repetition) appears when producing a suite of identically formatted visualizations for different variables. Pattern 4 (model-fitting repetition) occurs when fitting the same class of model across multiple predictor sets and summarizing results. In each case, the extraction follows the same logic: identify the variant, parameterize it, and wrap the invariant in a function.
SECTION 6
Worked Example — Cleaning and Summarizing Survey Data
Suppose you receive a survey dataset with three Likert-scale columns (q1, q2, q3), each containing integer responses from 1 to 5 with some missing values coded as 99. Your task is to recode 99 to NA, compute the mean and standard deviation, and report them. The original script does this three times with copy-pasted blocks.
Extracting a Survey-Column Cleaner
- 1
Step 1 — Identify the Duplication
The original script contains three blocks of the form:survey$q1[survey$q1 == 99] <- NA; mean_q1 <- mean(survey$q1, na.rm = TRUE); sd_q1 <- sd(survey$q1, na.rm = TRUE). The only element that varies across the three blocks is the column name. - 2
Step 2 — Catalog the Variant
The variant is the column vector itself. We will parameterize it asx. The sentinel value (99) could also be parameterized for generality, so we add asentinelparameter with a default of99.Parameters:x(column vector),sentinel = 99 - 3
Step 3 — Write the Extracted Function
We define:clean_and_summarize <- function(x, sentinel = 99) { x[x == sentinel] <- NA; list(mean = mean(x, na.rm = TRUE), sd = sd(x, na.rm = TRUE), n_valid = sum(!is.na(x))) }. The function returns a named list so we can easily access each statistic. Returning a list rather than printing ensures composability.Function defined:clean_and_summarize() - 4
Step 4 — Replace the Original Blocks
Each three-line block is replaced with a single call:results_q1 <- clean_and_summarize(survey$q1), and similarly forq2andq3. To process all three at once, we can uselapply(survey[, c("q1","q2","q3")], clean_and_summarize)which returns a list of results keyed by column name.9 lines of duplicated code → 1 function definition + 1lapplycall - 5
Step 5 — Verify Behavior Preservation
We compare outputs:all.equal(results_q1$mean, mean_q1_old)returnsTRUE. We repeat for all columns and statistics. Once verified, the original blocks can be safely deleted. For ongoing protection, wrap these checks intestthat::test_that()assertions so any future modification toclean_and_summarize()is automatically regression-tested.All outputs match ✓ — refactoring is complete
SECTION 7
Strengths, Limitations, and Trade-offs
Function extraction is arguably the most universally beneficial refactoring, but like any engineering practice it carries trade-offs. Over-extraction — decomposing code into too many tiny functions — can fragment logic and make the overall flow harder to follow. Under-extraction leaves code bloated and inconsistent. The table below contrasts the primary strengths and limitations to help you calibrate how aggressively to apply this technique.
| Dimension | Strength | Limitation / Risk |
|---|---|---|
| Readability | Descriptive function names replace opaque code blocks, making the script read like pseudocode. | Too many small functions force the reader to jump between definitions to understand the flow ("yo-yo" problem). |
| Maintainability | A bug fix or enhancement is applied in one place and propagates everywhere. | Changing a function's interface (adding/removing parameters) can break all call sites. |
| Testability | Isolated functions can be unit-tested with known inputs and expected outputs. | Functions with side effects (file I/O, global state) remain difficult to test and may require mocking. |
| Performance | Negligible overhead; R function-call cost is trivial compared to data operations. | In tight inner loops (millions of iterations), the overhead of function dispatch can accumulate — consider vectorization instead. |
| Reusability | Functions can be sourced across scripts or packaged into an R package for team-wide use. | Premature generalization (adding parameters "just in case") increases complexity without immediate benefit. |
Strengths and limitations of extracting repeated code into functions
KEY TAKEAWAY
SECTION 8
Connection to Advanced Functional Patterns
Extracting repeated code into named functions is the entry point to a much richer landscape of functional programming techniques available in R. Once you are comfortable writing simple extracted functions, the next step is to leverage R's higher-order functions — functions that accept other functions as arguments — to further eliminate repetitive patterns. The apply family (lapply, sapply, vapply) and the purrr package's map family provide the machinery to apply your extracted function across collections without writing a single for loop.
| Concept | Basic Extraction (This Lesson) | Advanced Functional Pattern |
|---|---|---|
| Applying to multiple inputs | Call the function once per input manually | Use lapply() or purrr::map() to iterate declaratively |
| Composing transformations | Chain function calls sequentially with intermediate variables | Use the pipe (|> or %>%) to compose a readable pipeline |
| Reusing across projects | Copy the function definition to another script via source() | Package functions into an R package with documentation, tests, and versioning |
| Customizing behavior | Add parameters with default values | Write function factories (closures) that return specialized functions |
Progression from basic extraction to advanced functional programming in R
The journey from duplicated code to a well-structured R package follows a clear trajectory: first extract into functions, then iterate with lapply or purrr::map, then compose with pipes, and finally bundle into a package. Each step builds directly on the foundation of function extraction covered in this lesson. Understanding closures — functions that capture and retain their enclosing environment — opens the door to function factories, memoization, and other powerful patterns that make R code both concise and expressive.
SECTION 9
Practice Problems
Question 1: CONCEPTUAL
Show answer
Question 2: BASIC CALCULATION
# Block A
min_a <- min(df$temperature, na.rm = TRUE)
max_a <- max(df$temperature, na.rm = TRUE)
df$temp_scaled <- (df$temperature - min_a) / (max_a - min_a)
# Block B
min_b <- min(df$humidity, na.rm = TRUE)
max_b <- max(df$humidity, na.rm = TRUE)
df$hum_scaled <- (df$humidity - min_b) / (max_b - min_b)Show answer
min_max_scale <- function(x) { rng <- range(x, na.rm = TRUE); (x - rng[1]) / (rng[2] - rng[1]) }. Call sites become: df$temp_scaled <- min_max_scale(df$temperature) and df$hum_scaled <- min_max_scale(df$humidity). Notice the use of range() to compute min and max in a single call, which is both more concise and marginally more efficient than two separate calls. The function is pure — it depends only on its input and produces no side effects — making it trivially testable.Question 3: INTERMEDIATE
year >= 2020, converts a date column from character to Date class, and writes the result to a new CSV. The logic is identical for all three files, differing only in the file paths. Write an extracted function with appropriate parameters and default values, then show how to apply it to all three files using lapply().Show answer
process_file <- function(input_path, output_path, min_year = 2020, date_col = "date") { d <- read.csv(input_path, stringsAsFactors = FALSE); d <- d[d$year >= min_year, ]; d[[date_col]] <- as.Date(d[[date_col]]); write.csv(d, output_path, row.names = FALSE); invisible(d) }. To apply across files: files_in <- c("data/q1.csv", "data/q2.csv", "data/q3.csv"); files_out <- gsub("data/", "clean/", files_in); results <- Map(process_file, files_in, files_out). We use Map() (the multivariate version of mapply) because we have two parallel lists of arguments. The invisible(d) allows optional capture of the cleaned data without cluttering the console.Question 4: APPLIED
y in a data frame df. For each model, you extract R², the coefficient estimate, and its p-value. Write a function fit_univariate() that accepts a predictor column name, fits the model, and returns these three summaries in a named list. Then demonstrate how to apply it across all five predictors and combine the results into a single data frame.Show answer
fit_univariate <- function(predictor, data, response = "y") { fml <- as.formula(paste(response, "~", predictor)); mod <- lm(fml, data = data); s <- summary(mod); coeff <- s$coefficients[2, ]; list(predictor = predictor, r_squared = s$r.squared, estimate = coeff["Estimate"], p_value = coeff["Pr(>|t|)"]) }. Application: predictors <- c("x1","x2","x3","x4","x5"); results <- lapply(predictors, fit_univariate, data = df); results_df <- do.call(rbind, lapply(results, as.data.frame)). The key design decision is accepting the predictor as a string and constructing the formula with as.formula(paste(...)), which allows programmatic iteration. The response variable has a default of "y" but can be overridden, making the function reusable for different datasets.Question 5: CRITICAL THINKING
center_fn and transform_fn that accept function objects. Discuss: (a) Under what circumstances is this a good design? (b) When might this over-generalization harm readability? (c) What R language feature makes this possible, and what concept from functional programming does it exemplify?Show answer
median or mean as arguments relies on R treating them as objects no different from numeric vectors or character strings.SUMMARY
Lesson Summary
Summary
Refactoring repeated code into functions is the most fundamental transformation in the R programmer's toolkit. The process begins with identifying duplicated blocks — code segments that share the same structure but differ in one or more values. Those varying values become function parameters, while the shared logic becomes the function body. A descriptive verb-noun function name serves as self-documentation, and default argument values encode the most common usage pattern.
The guiding principles — DRY, single responsibility, and behavior preservation — ensure that refactoring improves structure without introducing regressions. After extraction, verify correctness with all.equal() or testthat. The Rule of Three provides a practical threshold for when to extract: tolerate one copy, note two, and refactor at three. This foundational skill paves the way toward higher-order functions, functional pipelines, and ultimately R package development.