The Quantum Engineer

16. Quantum Programming

16.1Why Quantum Programming Is Different

Three differences reshape everything. One: no variables — state lives in a register you cannot read mid-flight, so classical control flow (if, while on data) is mostly unavailable; you pre-compute the whole circuit statically. Two: no copying — the same qubit can't be cloned into parallel branches, so algorithms are written as one fixed interference schedule. Three: results are distributions — every run returns a histogram, and your program's job is to make the histogram interpretable. The consequence: quantum "code" is mostly declarative circuit construction plus classical post-processing, and testing looks like statistical hypothesis testing, not unit tests. This chapter builds habits for all three.

16.2Qiskit

Qiskit (≥1.0, IBM, Python) is this book's SDK: the most widely deployed, best documented, and the one the biggest hardware fleet exposes. Install locally: pip install qiskit qiskit-aer matplotlib — no account needed for simulators. The object model is small: QuantumCircuit (your program), Primitive layers (SamplerV2 for distributions, EstimatorV2 for expectation values), BackendV2 (a target machine or simulator), and transpile (the compiler entry point). Cirq (Google), PennyLane (Xanadu, ML-oriented), and Amazon Braket are the other major stacks; the concepts here transfer — only the spelling changes. Version discipline matters: Qiskit 0.x→1.0 broke APIs; pin your versions.

16.3Circuit Construction

from qiskit import QuantumCircuit

qc = QuantumCircuit(2, 2)      # 2 qubits, 2 classical bits
qc.h(0)                        # H on q0
qc.cx(0, 1)                    # CNOT: control q0, target q1
qc.measure([0, 1], [0, 1])
print(qc)                      # text drawing

Construction is method calls in circuit order — the object is a list of instructions, and you can build circuits compositionally: qc.compose(other, qubits=[2,3]), qc.to_gate(), parameterized circuits QuantumCircuit(2); qc.rz(θ, 0) with Parameter objects for variational work (Ch. 59). Idiom: build small named circuits, compose them, and keep a make_circuit(**params) -> QuantumCircuit factory function rather than a mutable global — you will thank yourself in Part XIII.

16.4Registers

Qubits and clbits live in named registers: QuantumRegister(4, 'data'), ClassicalRegister(4, 'result'), passed to QuantumCircuit(data, result). Registers are addressing sugar — they let you write qc.measure(data, result) and keep circuits composable across width changes. Convention worth adopting from day one: separate registers by role — data, ancilla, syndrome — because Part X will have you managing hundreds of each, and "qubit 37" is meaningless while "syndrome[3]" is self-documenting. Under the hood, flat indexing still works and transpilation permutes it, which is why 16.11's result handling needs care.

16.5Measurements

qc.measure(qubit, clbit) records Z-basis results into classical bits; qc.measure_all() adds a barrier and measures everything into one register. Basis changes: apply H (or qc.barrier() then H) before measuring to read the X basis — a pattern you will use constantly (Deutsch–Jozsa, QFT, stabilizer checks). Mid-circuit measurement and classical feedback: with qc.if_test((creg, value)): branches on measured bits — dynamic circuits, supported on modern IBM hardware and essential to QEC decoders running in the loop. Measurement is not free either: it takes time (~600 ns on fixed-frequency transmons) that depth budgets must include.

16.6Simulators

Your main instrument. Aer (qiskit-aer) provides: AerSimulator() — exact state-vector simulation to ~30 qubits, with configurable noise models; method="statevector", "density_matrix" (mixed states), "stabilizer" (Clifford circuits, hundreds of qubits), "matrix_product_state" (low-entanglement circuits, 100+ qubits). Separately, Statevector.from_instruction(qc) gives you the exact amplitudes without sampling — the debugging goldmine (16.14). Noise models: NoiseModel.from_backend(backend) imports a real machine's calibration data, so your laptop experiments can preview hardware behavior. Rule of thumb: exact sim for logic bugs, noisy sim for statistics, hardware for truth.

16.7Transpilation

transpiled = transpile(qc, backend, optimization_level=3) is where your beautiful abstract circuit gets mangled into hardware reality: basis translation (your H becomes RZ–SX–RZ), routing (SWAP insertion for connectivity), optimization (gate cancellation, commutation analysis, ~20–40% depth reduction at level 3), and scheduling. Read the transpiled circuit — transpiled.count_ops(), transpiled.depth() — before running; it is the honest statement of what the machine will do. The pass manager is user-extensible (PassManager with custom passes), which is the gateway to the compiler engineering of Part XII. Optimization level 3 is slow but worth it for anything you publish.

16.8Backend Selection

Backends differ in: qubit count, gate fidelities, coherence times, connectivity, and queue times. Query them programmatically: backend.target exposes per-gate error rates and durations; backend.properties() (for real machines) gives the calibration snapshot. Selection heuristics that actually matter: pick the device whose two-qubit errors are lowest on the qubits your circuit will land on (not the device with the most qubits); check the coupling map against your circuit's interaction graph; and for serious work, run a calibration-age check — data older than a day is stale. Fake backends (GenericBackendV2(n)) snapshot real device properties for offline work — this book's default.

16.9Execution

The primitive workflow (Qiskit ≥1.0):

from qiskit.primitives import StatevectorSampler

sampler = StatevectorSampler()          # or runtime SamplerV2 for hardware
job = sampler.run([transpiled], shots=4096)
result = job.result()[0]
counts = result.data.meas.get_counts()  # {'00': 2041, '11': 2055, ...}

Execution is asynchronous (job submission → queue → results), batchable (many circuits per job — always batch), and shot-limited (hardware time is expensive; 4096 shots per circuit is a typical budget). The Sampler/Estimator split is the field's convergence point: distributions vs. expectation values, with error-mitigation options (ZNE, PEC — Ch. 31) attached at this layer. Wrap execution in your own functions with retry and logging from day one.

16.10Shot-Based Experiments

Everything measurable on a quantum computer is a sampling statistic over shots. Design experiments accordingly: choose shots from the statistics you need — estimating a probability to ±1% needs ~10⁴ shots (standard error √(p(1−p)/N)); distinguishing 0.500 from 0.505 needs ~10⁵. Vary one parameter per experiment (rotation angle, iteration count), record metadata (backend, calibration date, transpilation seed), and always run the ideal-simulator version alongside the noisy one — the delta is the noise, and studying it is Part IX in miniature. Keep a results log from your very first experiment; you are building a lab notebook, not running scripts.

16.11Retrieving Results

counts maps bit-strings to occurrences — with one subtlety that bites everyone: bit-string order. Qiskit prints q_{n-1}...q_0, little-endian: in '11' after the Bell circuit, the leftmost bit is q1. Your register layout and the printed order are transposed from what you'd expect; verify once with an asymmetric circuit (H on q0 only) and never guess again. Post-processing lives here: histogram → probabilities → whatever your application needs (expectation values, parity checks, maximum-likelihood decoding). Keep post-processing in separate, unit-tested functions from circuit construction — mixing them makes quantum bugs unfalsifiable.

16.12Visualizing Circuits

qc.draw('mpl') (matplotlib, publication-quality), qc.draw('text') (terminal), qc.draw('latex') (papers). For transpiled circuits, qc.draw('mpl', idle_wires=False) shows the physical layout, and plot_circuit_layout(transpiled, backend) overlays the coupling map — after which routing decisions become visible. Diagrams are debugging tools: if your H–CX–H doesn't look symmetric where you expected symmetry, you have a control/target bug. Parameterized circuits draw with θ labels. Make "draw before run" a hard rule: 30 seconds of eyes-on beats 30 minutes of confused counts.

16.13Visualizing Distributions

plot_histogram(counts) for shot data; for state insight, the Bloch vector (plot_bloch_multivector(Statevector(qc))) and the QSphere for multi-qubit amplitude-and-phase structure. The cityscape plot (bar chart over bit-strings) is your default, but sort and threshold it: dict(filter(lambda kv: kv[1] > 10, counts.items())). For parameter sweeps, plot the measured probability against the parameter — the interference fringes that emerge (Rabi oscillations, Ramsey fringes) are the signature that your circuit does what you think. Every figure in this book's experiments is reproducible with these three functions; make your own the same way.

16.14Debugging Quantum Programs

Your toolkit, in escalation order. One: Statevector(qc_without_measurements) — exact amplitudes; compare against hand-computed expectations for small n. Two: Operator(qc) — the full unitary matrix; compare with Operator(target) using np.allclose. Three: partial simulation — run the circuit in pieces, checking state after each stage. Four: assertions mid-circuit (simulator-only) and save_statevector in Aer for snapshots. Five: for probabilistic bugs, fix the seed (seed_simulator=42) to make failures reproducible before you debug them. The cardinal rule: never debug on hardware. Hardware debugging is called "error mitigation" and it is a research field, not a workflow.

16.15Testing Quantum Programs

Quantum code can have real tests. Property-based: your adder oracle satisfies f(a,b) = a+b mod 16 for all inputs — test exhaustively on the simulator (4-bit: 256 cases, milliseconds). Invariants: ancillas return to |0⟩ (test via Statevector marginalization); your circuit is unitary (Operator(qc).is_unitary()); probabilities sum to 1. Regression: snapshot gate counts and depth — a change that doubles depth should fail review. Statistical: acceptance thresholds with known shot noise ("P(1) within 0.5±3σ"). pytest wraps all of it. Part XVIII's projects come with test suites in this style; adopt them for everything you build, starting with Chapter 17's simulator.