Skip to content
Home » Software Design » Liskov Substitution Principle (LSP) in C

Liskov Substitution Principle (LSP) in C

Liskov Substitution Principle featured image with purple background, SOLID badge, L icon, LSP in C subtitle in the Software Design in C series by NerdyElectronics
Software Design Principles in C
Part 4 of 31View Full Path →

KEY TAKEAWAYS

  • LSP means: any implementation of an interface must be swappable without breaking the caller
  • Define a clear contract for each interface: what the inputs mean, what the return values mean, what side effects are expected
  • Document the contract in the header file as comments above the function pointer typedefs
  • If an implementation needs special handling (like flash erase), it must handle it internally — not push it to the caller

What Is the Liskov Substitution Principle?

The Liskov Substitution Principle (LSP) states: if module A can be replaced by module B through the same interface, the program should still work correctly.In simpler terms: if your code works with a “generic sensor,” then plugging in any specific sensor (temperature, humidity, pressure) through the same interface should not break anything. Every implementation must honor the contract of the interface.

Example 1: Storage Backend (Flash vs EEPROM)

Bad: Implementations behave differently through the “same” interface

typedef struct {
    int (*write)(uint32_t addr, const uint8_t *data, int len);
    int (*read)(uint32_t addr, uint8_t *data, int len);
} storage_driver_t;

/* Flash implementation */
int flash_write(uint32_t addr, const uint8_t *data, int len) {
    /* Flash requires erasing before writing! */
    /* But this function does NOT erase first */
    /* Caller gets corrupted data if sector was not erased */
    flash_program(addr, data, len);
    return len;
}

/* EEPROM implementation */
int eeprom_write(uint32_t addr, const uint8_t *data, int len) {
    /* EEPROM can overwrite directly - works fine */
    eeprom_program(addr, data, len);
    return len;
}
Problem: The caller uses storage->write() expecting it to just work. But the flash version silently corrupts data because it skips the required erase step. You cannot substitute flash for EEPROM — LSP is violated.

Good: Both implementations honor the same contract

typedef struct {
    int (*write)(uint32_t addr, const uint8_t *data, int len);
    int (*read)(uint32_t addr, uint8_t *data, int len);
    int (*erase)(uint32_t addr, int len);  /* Optional, may be no-op */
} storage_driver_t;

/* Flash: handles erase internally so write() always works */
int flash_write(uint32_t addr, const uint8_t *data, int len) {
    flash_erase_sector(addr);           /* Handles the requirement */
    flash_program(addr, data, len);
    return len;
}

int flash_erase(uint32_t addr, int len) {
    flash_erase_sector(addr);
    return 0;
}

/* EEPROM: write works directly, erase is a no-op */
int eeprom_write(uint32_t addr, const uint8_t *data, int len) {
    eeprom_program(addr, data, len);
    return len;
}

int eeprom_erase(uint32_t addr, int len) {
    return 0;  /* No erase needed, but interface is consistent */
}

/* Usage: caller does not care which storage is used */
void save_config(storage_driver_t *storage, config_t *cfg) {
    storage->write(CONFIG_ADDR, (uint8_t *)cfg, sizeof(config_t));
}

/* Both work correctly */
storage_driver_t flash_drv  = { flash_write,  flash_read,  flash_erase };
storage_driver_t eeprom_drv = { eeprom_write, eeprom_read, eeprom_erase };

save_config(&flash_drv, &my_config);   /* Works */
save_config(&eeprom_drv, &my_config);  /* Also works */

Example 2: Communication Interface (UART vs SPI)

Bad: SPI implementation changes the expected behavior

typedef struct {
    int (*send)(const uint8_t *data, int len);
    int (*receive)(uint8_t *buf, int max_len);
} comm_interface_t;

/* UART: returns number of bytes actually sent */
int uart_send(const uint8_t *data, int len) {
    for (int i = 0; i < len; i++) {
        uart_tx_byte(data[i]);
    }
    return len;  /* Returns bytes sent */
}

/* SPI: returns something completely different! */
int spi_send(const uint8_t *data, int len) {
    spi_transfer(data, len);
    return 0;  /* Returns 0 for "success" instead of byte count! */
}
Problem: The caller checks if (comm->send(data, 10) == 10) to verify all bytes were sent. UART returns 10, but SPI returns 0. The caller thinks SPI failed. LSP is violated because the return value contract is different.

Good: Both follow the same contract

/*
 * Contract: send() returns number of bytes successfully sent.
 *           Returns negative value on error.
 */
typedef struct {
    int (*send)(const uint8_t *data, int len);
    int (*receive)(uint8_t *buf, int max_len);
} comm_interface_t;

int uart_send(const uint8_t *data, int len) {
    for (int i = 0; i send(msg, len);
    if (sent != len) {
        handle_send_error(sent);
    }
}

comm_interface_t uart = { uart_send, uart_receive };
comm_interface_t spi  = { spi_send,  spi_receive };

send_message(&uart, data, 10);  /* Works */
send_message(&spi, data, 10);   /* Also works, same behavior */

The LSP Contract Checklist

When creating interchangeable implementations, ensure:
RuleDescription
Same return semanticsIf the interface says “returns byte count,” all implementations must return byte count
Same error handlingIf one returns -1 on error, all must return -1 on error
No hidden preconditionsDo not require callers to “erase before write” for some implementations only
No surprise side effectsOne implementation should not reset global state that others leave alone

Key Takeaways

  • LSP means: any implementation of an interface must be swappable without breaking the caller
  • Define a clear contract for each interface: what the inputs mean, what the return values mean, what side effects are expected
  • Document the contract in the header file as comments above the function pointer typedefs
  • If an implementation needs special handling (like flash erase), it must handle it internally — not push it to the caller

Leave a Reply

Your email address will not be published. Required fields are marked *