Constructing a Program
From Ada Lovelace and punched cards to Python, Rust and AI assistants: how a program is made, which language to use for what, how source code becomes a running program, and four small examples – the same program in three languages, Git, tests and an automatic backup on a home server.
Open in the interactive Digital Garden
| What You Need |
|---|
| Any computer – Windows, Linux or macOS – and a terminal |
| Python 3 for most examples; optionally a C compiler (GCC) and Rust for Example 1. On Linux they are one package command away; on Windows, MSYS2 or WSL make it easy |
| Git for Example 2 |
| A code editor such as VSCodium, Notepad++ or Thonny (made for beginners in Python) |
| For Example 4: a Linux server or an old PC – see Constructing a Cloud |
| All the tools in one place: my Programming & Development resources |
Jump to Examples
Every note in this garden ends with commands and code: a door opener on a microcontroller, a cloud on a Raspberry Pi, a small neural network in Python. This note is about the craft behind all of them – how a program is made. In Communication with the Machines I described the ladder from buttons down to assembly; programming is the skill of climbing down that ladder on purpose, writing instructions precise enough for a machine that does exactly what it is told and nothing more.
A program is not magic either. It is a text file written in a language with strict rules, a tool that turns that text into something the processor can run, and a lot of code written by other people that it builds on. Once you see those three parts, the difference between a ten-line script and a large application is mostly one of size and discipline.
A Bit of Programming History
The first published program was written for a machine that was never finished. In 1843 Ada Lovelace added her notes to a translation of an article about Charles Babbage's Analytical Engine, including a step-by-step method for calculating Bernoulli numbers – and the remark that such a machine could work with symbols, not only numbers. The first electronic computers of the 1940s were programmed with switches, cables and punched cards, directly in machine code. Even the word "bug" got its famous story then: in 1947 operators of the Harvard Mark II taped a moth that had caused a fault into their logbook as the "first actual case of bug being found".
Writing numbers for every instruction was slow and error-prone, so programmers started to write programs that write programs. Assembly languages gave the instructions short names. In 1952 Grace Hopper built the A-0 system, an early compiler, and argued – against much doubt – that computers could be programmed in something close to English. In 1957 IBM released FORTRAN, led by John Backus, for scientific calculations; it is still used for weather and engineering simulations. LISP (1958, John McCarthy) brought ideas that AI research used for decades, COBOL (1959) was made for business and still runs in many banks, and BASIC (1964, Dartmouth College) was made for beginners – later the first language of a whole generation of home-computer owners. As programs grew, so did the problems: in 1968 a NATO conference spoke of a "software crisis" and popularised the term software engineering.
In 1972 Dennis Ritchie created C at Bell Labs, and Unix was rewritten in it – the story is in Reviving an Old Computer. C is close enough to the hardware to write an operating system and portable enough to move it between machines; Linux, most microcontroller firmware and the interpreters of many other languages are still written in it. In the same year version control began with SCCS at Bell Labs. C++ (Bjarne Stroustrup, first commercial release 1985) added objects to C. The 1990s brought the languages of the Internet era: Python (Guido van Rossum, first release 1991), and in 1995 Java, JavaScript and PHP. Free software and the open-source movement (GNU from 1983, the term "open source" from 1998) meant that compilers, libraries and whole operating systems could be shared and improved by anyone.
The 2000s changed how people work together. In 2005 Linus Torvalds wrote Git to manage the development of the Linux kernel, and in 2008 GitHub and Stack Overflow made sharing code and asking questions a daily habit. Package managers such as pip (2008) and npm (2010) let a program pull in thousands of other people's libraries with one command – a huge convenience and, as the security section below shows, a new kind of risk. New languages tried to fix old problems: Go (announced by Google in 2009) for simple, fast servers, and Rust (version 1.0 in 2015) for programs that are as fast as C but safe from many memory errors; since 2022 Rust can also be used inside the Linux kernel.
The latest step is programming with AI. In 2021 GitHub introduced Copilot, an assistant that suggests code as you type, and today AI tools can write, explain and change whole files. In the Stack Overflow Developer Survey 2025, with more than 49,000 respondents, 84% said they use or plan to use AI tools – but 46% said they do not trust the accuracy of the results. Languages keep changing too: in the TIOBE index for September 2026, Python is first with about 18%, followed by C, C++, Java and C#, and Rust is in the top ten. How the models behind these assistants work is described in How AI Works.
A short timeline:
| 1843 – Ada Lovelace publishes the first program, for Babbage's Analytical Engine |
| 1940s – machine code, switches and punched cards; 1947 – the "first actual bug" |
| 1952 – Grace Hopper's A-0, an early compiler |
| 1957–1964 – FORTRAN, LISP, COBOL, BASIC |
| 1968 – the "software crisis" and the term software engineering |
| 1972 – C and Unix; SCCS, early version control |
| 1983–1998 – GNU, C++, Python (1991), Java and JavaScript (1995), the term "open source" |
| 2005–2010 – Git, GitHub, Stack Overflow, package managers (pip, npm) |
| 2009–2015 – Go and Rust |
| 2021 onwards – AI coding assistants |
Some resources for history:
- History of programming languages (Wikipedia)
- Timeline of programming languages (Wikipedia)
- Note G – Ada Lovelace's program (Wikipedia)
- Stack Overflow Developer Survey
- Computer History Museum
How a Program Is Made
Whatever the language, the path from an idea to a running program has the same stations. The table goes through them in order; the examples below show each one in practice.
From an idea to a running program:
| 1. The problem: what exactly should happen, with which input, and what counts as a correct result? "Back up my photos" becomes "every night at 2:30, pack the folder /srv/data into a dated archive and keep the last seven". Most bugs are born here, before any code is written. |
| 2. Source code: plain text in a programming language – instructions, variables, conditions, loops and functions. It is written for two readers: the machine, which needs it to be exact, and people (including you in a year), who need it to be clear. That is what comments and good names are for. |
| 3. Compiler or interpreter: a compiler (C, C++, Rust, Go) translates the whole program into machine code before it runs, producing a file you can start directly. An interpreter (Python, JavaScript, shell scripts) reads and runs the program step by step. Compiled programs are usually faster; interpreted ones are quicker to try and change. Java and C# are in between: they compile to an intermediate code that a runtime executes. What happens below that, down to the transistors, is in From Source Code to Silicon. |
| 4. Libraries and packages: almost no program starts from zero. The standard library of a language gives files, networking, dates and much more; packages from others are installed with a package manager – pip for Python, npm for JavaScript, Cargo for Rust. A modern application can depend on hundreds of them. |
| 5. Version control: Git records every change with a message, lets you go back, try ideas on a separate branch and work with others without overwriting each other's work (Example 2). |
| 6. Testing and debugging: small programs that check the main program automatically (Example 3), and a debugger that lets you stop a running program and look at its variables. Testing shows the presence of bugs, never their absence – but it finds most of them early, when they are cheap. |
| 7. Building and running: the result is installed where it will run – a desktop, a server, a container (see Constructing a Cloud) or a microcontroller (see Microcontroller Communication) – and from then on it has to be updated and looked after, like any other part of a system. |
Which language for what? (my own rough guide, not a ranking):
| Python: scripts, automation, data, AI and engineering calculations – the best first language for most people. Used in How AI Works and Structural Statics. |
| Shell (Bash, PowerShell): gluing commands together on one machine – short tasks; when a script grows beyond a page, Python is usually the better choice. See Troubleshooting Linux. |
| C and C++: microcontrollers, operating systems, drivers, games and anything where speed and control matter. Arduino sketches are C++. |
| JavaScript (and TypeScript), HTML, CSS: everything in a web browser – including this website. See my Web Design & Building resources. |
| Rust and Go: modern servers, command-line tools and system programs. Many self-hosted applications are written in Go; Rust is used where both speed and safety matter. |
| Java, C# and Kotlin: large business applications, Android apps (Kotlin, Java), Windows desktop applications and games in Unity (C#). |
| SQL: asking questions of databases. Not a general programming language, but almost every program that stores data needs a little of it. See Constructing a Database. |
A few observations. First, the first language matters less than people think: variables, conditions, loops, functions and data structures are the same ideas everywhere, and the second language is learned much faster. Second, reading code is as important as writing it – most of a programmer's time goes into understanding existing code. Third, the hard part is rarely the syntax; it is describing the problem exactly and dealing with everything that can go wrong.
Some resources for learning:
- The official Python tutorial
- The Rust Programming Language (free online book)
- Pro Git (free online book)
- CS50 – Harvard's free introduction to computer science
- My Learning & Documentation resources
- My Programming & Development resources
Examples: Writing, Saving, Testing and Running a Program
The four examples follow the stations above: the same program in three languages, keeping it in Git, testing it, and finally a small useful program that runs on its own on a home server. I ran all of them on Linux with Python 3.13, GCC 13 and Rust 1.97; the outputs shown are from those runs. On Windows, use python instead of python3.
Example 1 – The same program in Python, C and Rust
The task: count the lines, words and characters in a text file. Create a short file notes.txt to test with, for example:
My home server runs Debian.
It keeps my photos, my notes and my e-mail.
Every night it makes a backup.
Python – save as count.py and run it with the interpreter, no build step:
import sys
# Count lines, words and characters in a text file
with open(sys.argv[1], encoding="utf-8") as f:
text = f.read()
lines = text.count("\n")
words = len(text.split())
print(f"{lines} lines, {words} words, {len(text)} characters")
python3 count.py notes.txt
C – save as count.c, compile it into a program file, then run that file:
#include <stdio.h>
#include <ctype.h>
/* Count lines, words and characters in a text file */
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "usage: %s FILE\n", argv[0]);
return 1;
}
FILE *f = fopen(argv[1], "r");
if (f == NULL) {
perror(argv[1]);
return 1;
}
long lines = 0, words = 0, chars = 0;
int c, in_word = 0;
while ((c = fgetc(f)) != EOF) {
chars++;
if (c == '\n') lines++;
if (isspace(c)) {
in_word = 0;
} else if (!in_word) {
in_word = 1;
words++;
}
}
fclose(f);
printf("%ld lines, %ld words, %ld characters\n", lines, words, chars);
return 0;
}
gcc -Wall -O2 count.c -o count_c
./count_c notes.txt
Rust – save as count.rs; the compiler checks a lot before it produces a program:
use std::env;
use std::fs;
// Count lines, words and characters in a text file
fn main() {
let path = env::args().nth(1).expect("usage: count FILE");
let text = fs::read_to_string(&path).expect("cannot read the file");
let lines = text.lines().count();
let words = text.split_whitespace().count();
let chars = text.chars().count();
println!("{} lines, {} words, {} characters", lines, words, chars);
}
rustc -O count.rs -o count_rs
./count_rs notes.txt
All three print the same result:
3 lines, 20 words, 103 characters
The comparison is the lesson. Python needs eight lines because its standard library already knows how to split text into words. C needs thirty because it works one character at a time, and the programmer must check everything – a missing file, a missing argument – by hand; in return the compiled program is tiny (about 16 KB here) and fast. Rust sits in between: short like Python, compiled like C, and its compiler refuses many mistakes before the program ever runs; the program file is bigger (about 4 MB here) because the Rust standard library is built into it. The Linux command wc notes.txt gives the same three numbers – a small tool that has done this job since the first version of Unix in 1971.
Example 2 – Keeping the program in Git
Git turns a folder into a repository that remembers every version. Run these commands in the folder with count.py and notes.txt:
git init # start a repository in this folder
git config user.name "Your Name" # who you are (once per repository, or with --global)
git config user.email "you@example.org"
git add count.py notes.txt # choose the files for the next snapshot
git commit -m "First version of the counter"
git log --oneline # the history so far
Now try a change on a branch, a parallel line of work that does not disturb the main one:
git switch -c average # create a branch and switch to it
# ...add one line at the end of count.py:
# print(f"average word length: {sum(len(w) for w in text.split()) / words:.1f}")
git diff # what exactly changed?
git commit -am "Show the average word length"
git switch main # back to the main line
git merge average # bring the change in
git log --oneline
682ef0e Show the average word length
e493b85 First version of the counter
The numbers on the left are short versions of each commit's identifier; yours will be different. Two more habits: a file called .gitignore lists what Git should never store (temporary files, logs, passwords – see the security section), and a remote copy keeps the history safe and lets others work with you. That can be Codeberg, GitHub, or your own Forgejo server:
git remote add origin https://codeberg.org/yourname/counter.git
git push -u origin main
Example 3 – Testing: a program that checks a program
Python comes with a testing module, unittest, so nothing needs to be installed. Put two small functions into temps.py – the kind you might use for the temperature sensors in Microcontroller Communication:
def c_to_f(celsius):
"""Convert degrees Celsius to Fahrenheit."""
return celsius * 9 / 5 + 32
def average(values):
"""Average of a list of readings; an empty list is an error."""
if not values:
raise ValueError("no readings")
return sum(values) / len(values)
And the tests into test_temps.py, in the same folder:
import unittest
from temps import c_to_f, average
class TestTemps(unittest.TestCase):
def test_freezing_and_boiling(self):
self.assertEqual(c_to_f(0), 32)
self.assertEqual(c_to_f(100), 212)
def test_average(self):
self.assertAlmostEqual(average([20.0, 21.0, 22.5]), 21.17, places=2)
def test_empty_list_is_an_error(self):
with self.assertRaises(ValueError):
average([])
if __name__ == "__main__":
unittest.main()
python3 -m unittest -v
test_average (test_temps.TestTemps.test_average) ... ok
test_empty_list_is_an_error (test_temps.TestTemps.test_empty_list_is_an_error) ... ok
test_freezing_and_boiling (test_temps.TestTemps.test_freezing_and_boiling) ... ok
Ran 3 tests in 0.000s
OK
Now make a typical typing mistake in temps.py – write + 23 instead of + 32 – and run the tests again:
FAIL: test_freezing_and_boiling (test_temps.TestTemps.test_freezing_and_boiling)
self.assertEqual(c_to_f(0), 32)
AssertionError: 23.0 != 32
FAILED (failures=1)
The test caught the mistake at once and says exactly where. That is the point: every time you change the program, the tests check that the old things still work. Bigger projects run them automatically on every commit. For projects that need packages from others, create a separate virtual environment first (python3 -m venv .venv), so that each project has its own packages and versions and does not disturb the system; the popular pytest package, for example, makes tests even shorter.
Example 4 – A small program that works for you: an automatic backup
The last example is a real tool for a home server: pack a folder into a dated archive every night and keep only the newest seven. Save it as /usr/local/bin/backup.py:
#!/usr/bin/env python3
"""Pack a folder into a dated .tar.gz archive and keep only the newest copies."""
import sys
import tarfile
from datetime import datetime
from pathlib import Path
SOURCE = Path(sys.argv[1] if len(sys.argv) > 1 else "/srv/data")
TARGET = Path(sys.argv[2] if len(sys.argv) > 2 else "/srv/backups")
KEEP = 7 # how many archives to keep
def main():
if not SOURCE.is_dir():
sys.exit(f"source folder not found: {SOURCE}")
TARGET.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
archive = TARGET / f"{SOURCE.name}_{stamp}.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
tar.add(SOURCE, arcname=SOURCE.name)
print(f"created {archive} ({archive.stat().st_size / 1024:.1f} KB)")
# delete the oldest archives, keep the newest KEEP
old = sorted(TARGET.glob(f"{SOURCE.name}_*.tar.gz"))[:-KEEP]
for path in old:
path.unlink()
print(f"deleted {path.name}")
if __name__ == "__main__":
main()
Test it by hand first, with a test folder: python3 backup.py data backups. After the eighth run it starts to delete the oldest archive:
created backups/data_2026-10-16_141849.tar.gz (0.3 KB)
deleted data_2026-10-16_141841.tar.gz
Then let systemd run it every night. Create two small files:
# /etc/systemd/system/home-backup.service
[Unit]
Description=Nightly archive of /srv/data
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /usr/local/bin/backup.py /srv/data /srv/backups
# /etc/systemd/system/home-backup.timer
[Unit]
Description=Run home-backup every night
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl start home-backup.service # run it once now, as a test
journalctl -u home-backup.service # read what it printed
sudo systemctl enable --now home-backup.timer # switch on the nightly run
systemctl list-timers home-backup.timer # when is the next run?
Persistent=true means that a run missed because the server was off is done at the next start. Two honest warnings. An archive on the same disk protects against a deleted file, not against a dead disk – copy the archives to another machine or use a real backup tool such as restic or BorgBackup (the 3-2-1 rule is in Security Considerations). And a program that runs on its own must report problems: if the source folder is missing, the script ends with an error, and systemd marks the service as failed – check systemctl --failed now and then, as in Troubleshooting Linux.
Writing Code Safely
- Never put secrets in the code. Passwords, API keys and tokens belong in a separate configuration file or environment variable that is listed in
.gitignore. Anything that was ever committed to Git stays in its history, even if you delete it later – change such a password at once. - Choose dependencies carefully. Every package is code from a stranger that runs with your rights. In 2016 the removal of a tiny npm package called left-pad broke thousands of projects; in 2021 the Log4Shell vulnerability in a popular Java logging library affected countless servers; in 2024 a backdoor was found in the XZ Utils compression library (see Security Considerations). Prefer well-known, maintained packages, pin their versions and update them on purpose.
- Check every input. Data from a user, a file or the network can be wrong or hostile. Most classic attacks – SQL injection, command injection – come from treating input as code.
- Read code from AI assistants like code from a stranger. It can look perfect and still contain a bug, an outdated or invented library name, or an insecure shortcut. Understand it and test it before you run it, especially with
sudo. - Give programs only the rights they need. A backup script does not need to run as root if a dedicated user can read the folder; a web application does not need write access to its own code.
- Keep your own code in Git with a remote copy, and back up as for any other data. Your scripts are documentation of your systems.
A Few More Thoughts
From Ada Lovelace's notes to an AI assistant that writes a function in seconds, the core of programming has not changed: describe a problem so exactly that a machine can solve it, and think about everything that can go wrong. The tools did change, enormously. A free compiler, a free editor and Git on any old computer give anyone today more power than a university computing centre had fifty years ago.
For me, the best reason to learn some programming is not to become a programmer, but to stop being only a user. Even a small script that renames photos, checks a sensor or makes a backup every night changes the relationship with the machine: it does what you decided, not what someone else decided for you. AI assistants make the first steps easier, but they do not remove the need to understand the result – just as in Structural Statics, the computer will happily calculate a wrong model, and only the person who understands it will notice.