Cogs and Levers A blog full of technical stuff

Implementing an LRU Cache in Rust

Introduction

When building high-performance software, caches often play a vital role in optimizing performance by reducing redundant computations or avoiding repeated I/O operations. One such common caching strategy is the Least Recently Used (LRU) cache, which ensures that the most recently accessed data stays available while evicting the least accessed items when space runs out.

What Is an LRU Cache?

At its core, an LRU cache stores a limited number of key-value pairs. When you access or insert an item:

  • If the item exists, it is marked as “recently used.”
  • If the item doesn’t exist and the cache is full, the least recently used item is evicted to make space for the new one.

LRU caches are particularly useful in scenarios where access patterns favor recently used data, such as:

  • Web page caching in browsers.
  • Database query caching for repeated queries.
  • API response caching to reduce repeated external requests.

In this post, we’ll build a simple and functional implementation of an LRU cache in Rust. Instead of diving into complex data structures like custom linked lists, we’ll leverage Rust’s standard library collections (HashMap and VecDeque) to achieve:

  • Constant-time access and updates using HashMap.
  • Efficient tracking of usage order with VecDeque.

  • This straightforward approach is easy to follow and demonstrates Rust’s powerful ownership model and memory safety.

LRUCache Structure

We’ll begin with a struct that defines the cache:

pub struct LRUCache<K, V> {
    capacity: usize,                 // Maximum number of items the cache can hold
    map: HashMap<K, V>,              // Key-value store
    order: VecDeque<K>,              // Tracks the order of key usage
}

This structure holds:

  1. capacity: The maximum number of items the cache can store.
  2. map: The main storage for key-value pairs.
  3. order: A queue to maintain the usage order of keys.

Implementation

Our implementation of LRUCache includes some constraints on the generic types K (key) and V (value). Specifically, the K type requires the following traits:

impl<K: Clone + Eq + std::hash::Hash + PartialEq, V> LRUCache<K, V> {
}

The Clone trait allows us to create a copy of the key when needed (via .clone()). Eq is a trait that ensure that keys can be compared for equality and are either strictly equal or not. The Hash trait enables us to hash the keys which is a requirement for using HashMap, and finally the PartialEq trait allows for equality comparisons between two keys.

Technically Eq should already imply PartialEq but we explicity include it here for clarity.

Create the Cache

To initialize the cache, we add a new method:

pub fn new(capacity: usize) -> Self {
    LRUCache {
        capacity,
        map: HashMap::with_capacity(capacity),
        order: VecDeque::with_capacity(capacity),
    }
}
  • HashMap::with_capacity: Preallocates space for the HashMap to avoid repeated resizing.
  • VecDeque::with_capacity: Allocates space for tracking key usage.

Value access via get

The get method retrieves a value by key and updates its usage order:

pub fn get(&mut self, key: &K) -> Option<&V> {
    if self.map.contains_key(key) {
        // Move the key to the back of the order queue
        self.order.retain(|k| k != key);
        self.order.push_back(key.clone());
        self.map.get(key)
    } else {
        None
    }
}
  • Check if the key exists via contains_key
  • Remove the key from its old position in order and push it to the back
  • Return the vlaue from the HashMap

In cases where a value never existed or has been evicted, this function sends None back to the caller.

Value insertion via put

The put method adds a new key-value pair or updates an existing one:

pub fn put(&mut self, key: K, value: V) {
    if self.map.contains_key(&key) {
        // Update existing key's value and mark it as most recently used
        self.map.insert(key.clone(), value);
        self.order.retain(|k| k != &key);
        self.order.push_back(key);
    } else {
        if self.map.len() == self.capacity {
            // Evict the least recently used item
            if let Some(lru_key) = self.order.pop_front() {
                self.map.remove(&lru_key);
            }
        }
        self.map.insert(key.clone(), value);
        self.order.push_back(key);
    }
}
  • If the key exists
    • The value is updated in map
    • The key is moved to the back of order
  • If the cache is full
    • Remove the least recently used key (which will be the front of order) from map
  • Insert the new key-value pair and mark it as recently used

Size

Finally, we add a helper method to get the current size of the cache:

pub fn len(&self) -> usize {
    self.map.len()
}

Testing

Now we can test our cache:

fn main() {
    let mut cache = LRUCache::new(3);

    cache.put("a", 1);
    cache.put("b", 2);
    cache.put("c", 3);

    println!("{:?}", cache.get(&"a")); // Some(1)
    cache.put("d", 4); // Evicts "b"
    println!("{:?}", cache.get(&"b")); // None
    println!("{:?}", cache.get(&"c")); // Some(3)
    println!("{:?}", cache.get(&"d")); // Some(4)
}

Running this code, we see the following:

Some(1)
None
Some(3)
Some(4)

Conclusion

In this post, we built a simple yet functional LRU cache in Rust. A full implementation can be found as a gist here.

While this implementation is perfect for understanding the basic principles, it can be extended further with:

  • Thread safety using synchronization primitives like Mutex or RwLock.
  • Custom linked structures for more efficient eviction and insertion.
  • Diagnostics and monitoring to observe cache performance in real-world scenarios.

If you’re looking for a robust cache for production, libraries like lru offer feature-rich implementations. But for learning purposes, rolling your own cache is an excellent way to dive deep into Rust’s collections and ownership model.

Building a Packet Sniffer with Raw Sockets in C

Introduction

Network packet sniffing is an essential skill in the toolbox of any systems programmer or network engineer. It enables us to inspect network traffic, debug communication issues, and even learn how various networking protocols function under the hood.

In this article, we will walk through the process of building a simple network packet sniffer in C using raw sockets.

Before we begin, it might help to run through a quick networking primer.

OSI and Networking Layers

Before diving into the code, let’s briefly revisit the OSI model—a conceptual framework that standardizes network communication into seven distinct layers:

  1. Physical Layer: Deals with the physical connection and transmission of raw data bits.
  2. Data Link Layer: Responsible for framing and MAC addressing. Ethernet operates at this layer.
  3. Network Layer: Handles logical addressing (IP addresses) and routing. This layer is where IP packets are structured.
  4. Transport Layer: Ensures reliable data transfer with protocols like TCP and UDP.
  5. Session Layer: Manages sessions between applications.
  6. Presentation Layer: Transforms data formats (e.g., encryption, compression).
  7. Application Layer: Interfaces directly with the user (e.g., HTTP, FTP).

Our packet sniffer focuses on Layers 2 through 4. By analyzing Ethernet, IP, TCP, UDP, and ICMP headers, we gain insights into packet structure and how data travels across a network.

The Code

In this section, we’ll run through the functions that are needed to implement our packet sniffer. The layers that we’ll focus on are:

  • Layer 2 (Data Link): Capturing raw Ethernet frames and extracting MAC addresses.
  • Layer 3 (Network): Parsing IP headers for source and destination IPs.
  • Layer 4 (Transport): Inspecting TCP, UDP, and ICMP protocols to understand port-level communication and message types.

The Data Link Layer is responsible for the physical addressing of devices on a network. It includes the Ethernet header, which contains the source and destination MAC addresses. In this section, we analyze and print the Ethernet header.

void print_eth_header(unsigned char *buffer, int size) { 
    struct ethhdr *eth = (struct ethhdr *)buffer;

    printf("\nEthernet Header\n");
    printf("   |-Source Address      : %.2X-%.2X-%.2X-%.2X-%.2X-%.2X \n",
           eth->h_source[0], eth->h_source[1], eth->h_source[2], eth->h_source[3], eth->h_source[4], eth->h_source[5]);
    printf("   |-Destination Address : %.2X-%.2X-%.2X-%.2X-%.2X-%.2X \n",
           eth->h_dest[0], eth->h_dest[1], eth->h_dest[2], eth->h_dest[3], eth->h_dest[4], eth->h_dest[5]);
    printf("   |-Protocol            : %u \n", (unsigned short)eth->h_proto);
}

Layer 3 (Network)

The Network Layer handles logical addressing and routing. In our code, this corresponds to the IP header, where we extract source and destination IP addresses.

void print_ip_header(unsigned char *buffer, int size) { 
    struct iphdr *ip = (struct iphdr *)(buffer + sizeof(struct ethhdr));

    printf("\nIP Header\n");
    printf("   |-Source IP        : %s\n", inet_ntoa(*(struct in_addr *)&ip->saddr));
    printf("   |-Destination IP   : %s\n", inet_ntoa(*(struct in_addr *)&ip->daddr));
    printf("   |-Protocol         : %d\n", ip->protocol);
}

Here, we use the iphdr structure to parse the IP header. The inet_ntoa function converts the source and destination IP addresses from binary format to a human-readable string.

Layer 4 (Transport)

The Transport Layer ensures reliable data transfer and includes protocols like TCP, UDP, and ICMP. We have specific functions to parse and display these packets:

The TCP version of this function has a source and destination for the packet, but also has a sequence and acknowledgement which are key features for this protocol.

void print_tcp_packet(unsigned char *buffer, int size) {
    struct iphdr *ip = (struct iphdr *)(buffer + sizeof(struct ethhdr));
    struct tcphdr *tcp = (struct tcphdr *)(buffer + sizeof(struct ethhdr) + ip->ihl * 4);

    printf("\nTCP Packet\n");
    print_ip_header(buffer, size);
    printf("\n   |-Source Port      : %u\n", ntohs(tcp->source));
    printf("   |-Destination Port : %u\n", ntohs(tcp->dest));
    printf("   |-Sequence Number  : %u\n", ntohl(tcp->seq));
    printf("   |-Acknowledgement  : %u\n", ntohl(tcp->ack_seq));
}

The UDP counterpart doesn’t have the sequencing or acknowledgement as it’s a general broadcast protocol.

void print_udp_packet(unsigned char *buffer, int size) {
    struct iphdr *ip = (struct iphdr *)(buffer + sizeof(struct ethhdr));
    struct udphdr *udp = (struct udphdr *)(buffer + sizeof(struct ethhdr) + ip->ihl * 4);

    printf("\nUDP Packet\n");
    print_ip_header(buffer, size);
    printf("\n   |-Source Port      : %u\n", ntohs(udp->source));
    printf("   |-Destination Port : %u\n", ntohs(udp->dest));
    printf("   |-Length           : %u\n", ntohs(udp->len));
}

ICMP’s type, code, and checksum are used in the verification process of this protocol.

void print_icmp_packet(unsigned char *buffer, int size) {
    struct iphdr *ip = (struct iphdr *)(buffer + sizeof(struct ethhdr));
    struct icmphdr *icmp = (struct icmphdr *)(buffer + sizeof(struct ethhdr) + ip->ihl * 4);

    printf("\nICMP Packet\n");
    print_ip_header(buffer, size);
    printf("\n   |-Type : %d\n", icmp->type);
    printf("   |-Code : %d\n", icmp->code);
    printf("   |-Checksum : %d\n", ntohs(icmp->checksum));
}

Tying it all together

The architecture of this code is fairly simple. The main function sets up a loop which will continually receive raw information from the socket. From there, a determination is made about what level the information is at. Using this information we’ll call/dispatch to a function that specialises in that layer.

int main() {
    int sock_raw;
    struct sockaddr saddr;
    socklen_t saddr_len = sizeof(saddr);

    unsigned char *buffer = (unsigned char *)malloc(BUFFER_SIZE);
    if (buffer == NULL) {
        perror("Failed to allocate memory");
        return 1;
    }

    sock_raw = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
    if (sock_raw < 0) {
        perror("Socket Error");
        free(buffer);
        return 1;
    }

    printf("Starting packet sniffer...\n");

    while (1) {
        int data_size = recvfrom(sock_raw, buffer, BUFFER_SIZE, 0, &saddr, &saddr_len);
        if (data_size < 0) {
            perror("Failed to receive packets");
            break;
        }
        process_packet(buffer, data_size);
    }

    close(sock_raw);
    free(buffer);
    return 0;
}

The recvfrom receives the raw bytes in from the socket.

The process_packet function is responsible for the dispatch of the information. This is really a switch statement focused on the incoming protocol:

void process_packet(unsigned char *buffer, int size) {
    struct iphdr *ip_header = (struct iphdr *)(buffer + sizeof(struct ethhdr));

    switch (ip_header->protocol) {
        case IPPROTO_TCP:
            print_tcp_packet(buffer, size);
            break;
        case IPPROTO_UDP:
            print_udp_packet(buffer, size);
            break;
        case IPPROTO_ICMP:
            print_icmp_packet(buffer, size);
            break;
        default:
            print_ip_header(buffer, size);
            break;
    }
}

This then ties all of our functions in together.

Running

Because of the nature of the information that this application will pull from your system, you will need to run this as root. You need that low-level access to your networking stack.

sudo ./psniff

Conclusion

Building a network packet sniffer using raw sockets in C offers valuable insight into how data flows through the network stack and how different protocols interact. By breaking down packets layer by layer—from the Data Link Layer (Ethernet) to the Transport Layer (TCP, UDP, ICMP)—we gain a deeper understanding of networking concepts and system-level programming.

This project demonstrates key topics such as:

  • Capturing raw packets using sockets.
  • Parsing headers to extract meaningful information.
  • Mapping functionality to specific OSI layers.

Packet sniffers like this are not only useful for learning but also serve as foundational tools for network diagnostics, debugging, and security monitoring. However, it’s essential to use such tools ethically and responsibly, adhering to legal and organizational guidelines.

In the future, we could extend this sniffer by writing packet payloads to a file, adding packet filtering (e.g., only capturing HTTP or DNS traffic), or even integrating with libraries like libpcap for more advanced use cases.

A full gist of this code is available to check out.

Intercepting Linux Syscalls with Kernel Probes

Introduction

n this tutorial, we will explore how to write a Linux kernel module that intercepts system calls using kernel probes (kprobes).

Instead of modifying the syscall table—a risky and outdated approach—we will use kprobes, an officially supported and safer method to trace and modify kernel behavior dynamically.

What Are System Calls?

System calls are the primary mechanism by which user-space applications interact with the operating system’s kernel. They provide a controlled gateway to hardware and kernel services. For example, opening a file uses the open syscall, while reading data from it uses the read syscall.

What Are Kernel Probes?

Kprobes are a powerful debugging and tracing mechanism in the Linux kernel. They allow developers to dynamically intercept and inject logic into almost any kernel function, including system calls. Kprobes work by placing breakpoints at specific addresses in kernel code, redirecting execution to custom handlers.

Using kprobes, you can intercept system calls like close to log parameters, modify behavior, or gather debugging information, all without modifying the syscall table or kernel memory structures.

The Code

We have some preparation steps in order to be able to do Linux Kernel module development. If your system is already setup to do this, you can skip the first section here.

Before we start, remember to do this in a safe environment. Use a virtual machine or a disposable system for development. Debugging kernel modules can lead to crashes or instability.

Prerequisites

First up, we need to install the prerequisite software in order to write and build modules:

sudo apt-get install build-essential linux-headers-$(uname -r)

Module code

Now we can write some code that will actually be our kernel module.

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>

MODULE_LICENSE("GPL");

static struct kprobe kp = {
    .symbol_name = "__x64_sys_close",
};

static int handler_pre(struct kprobe *p, struct pt_regs *regs) {
    printk(KERN_INFO "Intercepted close syscall: fd=%ld\n", regs->di);
    return 0;
}

static int __init kprobe_init(void) {
    int ret;

    kp.pre_handler = handler_pre;
    ret = register_kprobe(&kp);
    if (ret < 0) {
        printk(KERN_ERR "register_kprobe failed, returned %d\n", ret);
        return ret;
    }

    printk(KERN_INFO "Kprobe registered\n");
    return 0;
}

static void __exit kprobe_exit(void) {
    unregister_kprobe(&kp);
    printk(KERN_INFO "Kprobe unregistered\n");
}

module_init(kprobe_init);
module_exit(kprobe_exit);

Breakdown

First up, we have our necessary headers for kernel development and the module license:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>

MODULE_LICENSE("GPL");

This ensures compatibility with GPL-only kernel symbols and enables proper loading of the module.

Next, the kprobe structure defines the function to be intercepted by specifying its symbol name. Here, we target __x64_sys_close:

static struct kprobe kp = {
    .symbol_name = "__x64_sys_close",
};

This tells the kernel which function to monitor dynamically.

The handler_pre function is executed before the intercepted function runs. It logs the file descriptor (fd) argument passed to the close syscall:

static int handler_pre(struct kprobe *p, struct pt_regs *regs) {
    printk(KERN_INFO "Intercepted close syscall: fd=%ld\n", regs->di);
    return 0;
}

In this case, regs->di contains the first argument to the syscall (the file descriptor).

The kprobe_init function initialises the kprobe, registers the handler, and logs its status. If registration fails, an error message is printed:

static int __init kprobe_init(void) {
    int ret;

    kp.pre_handler = handler_pre;
    ret = register_kprobe(&kp);
    if (ret < 0) {
        printk(KERN_ERR "register_kprobe failed, returned %d\n", ret);
        return ret;
    }

    printk(KERN_INFO "Kprobe registered\n");
    return 0;
}

The kprobe_exit function unregisters the kprobe to ensure no stale probes are left in the kernel:

static void __exit kprobe_exit(void) {
    unregister_kprobe(&kp);
    printk(KERN_INFO "Kprobe unregistered\n");
}

Finally, just like usual we define the entry and exit points for our module:

module_init(kprobe_init);
module_exit(kprobe_exit);

Building

Now that we’ve got our module code, we can can build and install our module. The following Makefile will allow us to build our code:

obj-m += syscall_interceptor.o

all:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
        make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

We build the module:

make

After a successful build, you should be left with a ko file. In my case it’s called syscall_interceptor.ko. This is the module that we’ll install into the kernel with the following:

sudo insmod syscall_interceptor.ko

Verify

Let’s check dmesg to verify it’s working. As we’ve hooked the close call we should end up with a flood of messages to verify:

dmesg | tail

You should see something like this:

[  266.615596] Intercepted close syscall: fd=-60473131794600
[  266.615596] Intercepted close syscall: fd=-60473131794600
[  266.615597] Intercepted close syscall: fd=-60473131794600
[  266.615600] Intercepted close syscall: fd=-60473131794600
[  266.615731] Intercepted close syscall: fd=-60473131925672

You can unload this module with rmmod:

sudo rmmod syscall_interceptor

Understand Kprobe Handlers

Kprobe handlers allow you to execute custom logic at various stages of the probed function’s execution:

  • Pre-handler: Runs before the probed instruction.
  • Post-handler: Runs after the probed instruction (not used in this example).
  • Fault handler: Runs if an exception occurs during the probe.

Modify the module to add post- or fault-handling logic as needed.

Clean Up

Always unregister kprobes in the module’s exit function to prevent leaving stale probes in the kernel. Use dmesg to debug any issues during module loading or unloading.

Caveats and Considerations

  1. System Stability: Ensure your handlers execute quickly and avoid blocking operations to prevent affecting system performance.
  2. Kernel Versions: Kprobes are supported in modern kernels, but some symbols may vary between versions.
  3. Ethical Usage: Always ensure you have permission to test and use such modules.

Conclusion

Using kprobes, you can safely and dynamically intercept system calls without modifying critical kernel structures. This tutorial demonstrates a clean and modern approach to syscall interception, avoiding deprecated or risky techniques like syscall table modification.

Creating extensions in C for PostgreSQL

Introduction

PostgreSQL allows developers to extend its functionality with custom extensions written in C. This powerful feature can be used to add new functions, data types, or even custom operators to your PostgreSQL instance.

In this blog post, I’ll guide you through creating a simple “Hello, World!” C extension for PostgreSQL and demonstrate how to compile and test it in a Dockerized environment. Using Docker ensures that your local system remains clean while providing a reproducible setup for development.

Development

There are a few steps that we need to walk through in order to get your development environment up and running as well as some simple boilerplate code.

The Code

First, create a working directory for your project:

mkdir postgres_c_extension && cd postgres_c_extension

Now, create a file named example.c and add the following code:

#include "postgres.h"
#include "fmgr.h"
#include "utils/builtins.h"  // For cstring_to_text function

PG_MODULE_MAGIC;

PG_FUNCTION_INFO_V1(hello_world);

Datum
hello_world(PG_FUNCTION_ARGS)
{
    text *result = cstring_to_text("Hello, World!");
    PG_RETURN_TEXT_P(result);
}

This code defines a simple PostgreSQL function hello_world() that returns the text “Hello, World!”. It uses PostgreSQL’s C API, and the cstring_to_text function ensures that the string is properly converted to a PostgreSQL text type.

Let’s take a closer look at a few pieces of that code snippet.

PG_MODULE_MAGIC

PG_MODULE_MAGIC;

This macro is mandatory in all PostgreSQL C extensions. It acts as a marker to ensure that the extension was compiled with a compatible version of PostgreSQL. Without it, PostgreSQL will refuse to load the module, as it cannot verify compatibility.

PG_FUNCTION_INFO_V1

PG_FUNCTION_INFO_V1(hello_world);

This macro declares the function hello_world() as a PostgreSQL-compatible function using version 1 of PostgreSQL’s call convention. It ensures that the function can interact with PostgreSQL’s internal structures, such as argument parsing and memory management.

Datum

Datum hello_world(PG_FUNCTION_ARGS)
  • Datum is a core PostgreSQL data type that represents any value passed to or returned by a PostgreSQL function. It is a general-purpose type used internally by PostgreSQL to handle various data types efficiently.
  • PG_FUNCTION_ARGS is a macro that defines the function signature expected by PostgreSQL for dynamically callable functions. It gives access to the arguments passed to the function.

In this example, Datum is the return type of the hello_world function.

PG_RETURN_TEXT_P

text *result = cstring_to_text("Hello, World!");
PG_RETURN_TEXT_P(result);
  • cstring_to_text: This function converts a null-terminated C string (char *) into a PostgreSQL text type. PostgreSQL uses its own text structure to manage string data.
  • PG_RETURN_TEXT_P: This macro wraps a pointer to a text structure and converts it into a Datum, which is required for returning values from a PostgreSQL C function.

The flow in this function:

  • cstring_to_text("Hello, World!") creates a text * object in PostgreSQL’s memory context.
  • PG_RETURN_TEXT_P(result) ensures the text * is properly wrapped in a Datum so PostgreSQL can use the return value.

Control and SQL Files

A PostgreSQL extension requires a control file to describe its metadata and a SQL file to define the functions it provides.

Create a file named example.control:

default_version = '1.0'
comment = 'Example PostgreSQL extension'

Next, create example--1.0.sql to define the SQL function:

CREATE FUNCTION hello_world() RETURNS text
AS 'example', 'hello_world'
LANGUAGE C IMMUTABLE STRICT;

Setting Up the Build System

To build the C extension, you’ll need a Makefile. Create one in the project directory:

MODULES = example
EXTENSION = example
DATA = example--1.0.sql
PG_CONFIG = pg_config
OBJS = $(MODULES:%=%.o)

PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

This Makefile uses PostgreSQL’s pgxs build system to compile the C code into a shared library that PostgreSQL can load.

Build Environment

To keep your development environment clean, we’ll use Docker. Create a Dockerfile to set up a build environment and compile the extension:

FROM postgres:latest

RUN apt-get update && apt-get install -y \
    build-essential \
    postgresql-server-dev-all \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /usr/src/example
COPY . .

RUN make && make install

Build the Docker image:

docker build -t postgres-c-extension .

Start a container using the custom image:

docker run --name pg-c-demo -e POSTGRES_PASSWORD=postgres -d postgres-c-extension

Testing

Access the PostgreSQL shell in the running container:

docker exec -it pg-c-demo psql -U postgres

Run the following SQL commands to create and test the extension:

CREATE EXTENSION example;
SELECT hello_world();

You should see the output:

 hello_world 
--------------
 Hello, World!
(1 row)

Cleaning Up

When you’re finished, stop and remove the container:

docker stop pg-c-demo && docker rm pg-c-demo

Conclusion

By following this guide, you’ve learned how to create a simple C extension for PostgreSQL, compile it, and test it in a Dockerized environment. This example can serve as a starting point for creating more complex extensions that add custom functionality to PostgreSQL. Using Docker ensures a clean and reproducible setup, making it easier to focus on development without worrying about system dependencies.

Understanding the ? Operator

Introduction

The ? operator in Rust is one of the most powerful features for handling errors concisely and gracefully. However, it’s often misunderstood as just syntactic sugar for .unwrap(). In this post, we’ll dive into how the ? operator works, its differences from .unwrap(), and practical examples to highlight its usage.

What is it?

The ? operator is a shorthand for propagating errors in Rust. It simplifies error handling in functions that return a Result or Option. Here’s what it does:

  • For Result:
    • If the value is Ok, the inner value is returned.
    • If the value is Err, the error is returned to the caller.
  • For Option:
    • If the value is Some, the inner value is returned.
    • If the value is None, it returns None to the caller.

This allows you to avoid manually matching on Result or Option in many cases, keeping your code clean and readable.

How ? Differs from .unwrap()

At first glance, the ? operator might look like a safer version of .unwrap(), but they serve different purposes:

  1. Error Propagation:
    • ? propagates the error to the caller, allowing the program to handle it later.
    • .unwrap() panics and crashes the program if the value is Err or None.
  2. Use in Production:
    • ? is ideal for production code where you want robust error handling.
    • .unwrap() should only be used when you are absolutely certain the value will never be an error (e.g., in tests or prototypes).

Examples

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?; // Propagate error if it occurs
    Ok(contents)
}

fn main() {
    match read_file("example.txt") {
        Ok(contents) => println!("File contents:\n{}", contents),
        Err(err) => eprintln!("Error reading file: {}", err),
    }
}

In this example, the ? operator automatically returns any error from std::fs::read_to_string to the caller, saving you from writing a verbose match.

The match is then left as an exercise to the calling code; in this case main.

How it Differs from .unwrap()

Compare the ? operator to .unwrap():

Using ?:

fn safe_read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?; // Error is propagated
    Ok(contents)
}

Using .unwrap():

fn unsafe_read_file(path: &str) -> String {
    let contents = std::fs::read_to_string(path).unwrap(); // Panics on error
    contents
}

If std::fs::read_to_string fails:

  • The ? operator propagates the error to the caller.
  • .unwrap() causes the program to panic, potentially crashing your application.

Error Propagation in Action

The ? operator shines when you need to handle multiple fallible operations:

fn process_file(path: &str) -> Result<(), std::io::Error> {
    let contents = std::fs::read_to_string(path)?;
    let lines: Vec<&str> = contents.lines().collect();
    std::fs::write("output.txt", lines.join("\n"))?;
    Ok(())
}

fn main() {
    if let Err(err) = process_file("example.txt") {
        eprintln!("Error processing file: {}", err);
    }
}

Here, the ? operator simplifies error handling for both read_to_string and write, keeping the code concise and readable.

Saving typing

Using ? is equivalent to a common error propagation pattern:

Without ?:

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = match std::fs::read_to_string(path) {
        Ok(val) => val,
        Err(err) => return Err(err), // Explicitly propagate the error
    };
    Ok(contents)
}

With ?:

fn read_file(path: &str) -> Result<String, std::io::Error> {
    let contents = std::fs::read_to_string(path)?; // Implicitly propagate the error
    Ok(contents)
}

Chaining

You can also chain multiple operations with ?, making it ideal for error-prone workflows:

async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?.text().await?;
    Ok(response)
}

#[tokio::main]
async fn main() {
    match fetch_data("https://example.com").await {
        Ok(data) => println!("Fetched data: {}", data),
        Err(err) => eprintln!("Error fetching data: {}", err),
    }
}

Conclusion

The ? operator is much more than syntactic sugar for .unwrap(). It’s a powerful tool that:

  • Simplifies error propagation.
  • Keeps your code clean and readable.
  • Encourages robust error handling in production.

By embracing the ? operator, you can write concise, idiomatic Rust code that gracefully handles errors without sacrificing clarity or safety.