- Published on
Applying SOLID Principles to Procedural Programming in C
- Authors
- Name
- Kanelis Elias
What is SOLID
SOLID is an acronym that represents a set of five design principles in software development, primarily aimed at creating more maintainable, flexible and robust object-oriented code. Robert C. Martin introduced these principles in his 2000 paper Design Principles and Design Patterns and are widely regarded as fundamental guidelines for writing clean code.
Michael Feathers sent an email to Robert and said that if he re-arranged the principles their first name would spell the word SOLID. Each letter in the acronym corresponds to a specific principle, introducing the SOLID acronym in this manner.
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Does SOLID apply to procedural programming languages like C?
Yes! While the SOLID principles were originally formulated for object-oriented programming, their core concepts are not limited to a single paradigm. In the following sections, we will explore how each SOLID principle can be adapted and applied to the realm of procedural programming using the C language. By doing so, we aim to bridge the gap between these principles and the procedural approach, demonstrating how their principles can still contribute to well-organized and maintainable code in C.
Some of the SOLID principles dictate the usage of polymorphism and inheritance. The important thing to remember is that it is the programmer's responsibility to not shoot to his foot. While C permits inheritance and polymorphism, it's important to recognize that these conveniences are accompanied by the necessity of performing type checking and implementing safeguards against undefined behavior. C++ contains all this OOP syntactic sugar to aid programmers with these gray areas.
In a future post, we will discuss how to use composition instead of polymorphism.
Adapt SOLID to the procedure paradigm that C follows.
1. Single Responsibility Principle (SRP):
A module1 should be responsible to one, and only one, actor2.
Functions or modules should have a single, well-defined responsibility. A function or module should have one, and only one, reason to be changed (e.g. rewritten).
A key concept for understanding this principle is that actors and only actors are requesting a change in a function or module. They are the reason for the change. You don’t want to confuse those actors, or yourself, by mixing the code that many different actors care about for different reasons.
It is just another way to define cohesion and coupling. We want to increase the cohesion between things that change for the same reasons and we want to decrease the coupling between those things that change for different reasons.
Example:
Consider the following simple console-based program written in C for calculating the area of either a circle or a rectangle based on user input.
#include <stdio.h>
#define PI 3.14159
void compute_area_circle()
{
float radius;
printf( "Enter the radius of the circle: " );
scanf( "%f", &radius );
float area = PI * radius * radius;
printf( "Circle Area: %.2f\n", area );
}
void compute_area_rectangle()
{
float width, height;
printf( "Enter the width and height of the rectangle: " );
scanf( "%f %f", &width, &height );
float area = width * height;
printf( "Rectangle Area: %.2f\n", area );
}
int main()
{
int errorCode = 0;
int choice;
printf( "Select a shape to calculate its area:\n" );
printf( "Type '0' for Circle\n" );
printf( "Type '1' for Rectangle\n" );
scanf( "%d", &choice );
if( choice == 0 )
{
compute_area_circle();
}
else if( choice == 1 )
{
compute_area_rectangle();
}
else
{
printf( "Invalid choice.\n" );
errorCode = 1;
}
return errorCode;
}
While this code is small and functional, it could benefit from improved organization to align with the Single Responsibility Principle. Currently, the functions perform a lot of subtasks such as user input, process of data, and user output, resulting in code that is disorganized and challenging to maintain. After the refactoring, each function will get a clear responsibility. This will make the code more organized and maintainable.
Let's refactor the code by applying to apply the Single Responsibility Principle.
#include <stdio.h>
#define PI 3.14159
/*****************************************************************************/
// Get user input for the circle
void getCircleUserInput( float *radius )
{
printf( "Enter the radius of the circle: " );
scanf( "%f", radius );
}
// Get the area of the circle
float calculateCircleArea( float radius )
{
return PI * radius * radius;
}
// Print the area of the circle
void printCircleArea( const float area )
{
printf( "Circle Area: %.2f\n", area );
}
/*****************************************************************************/
// Get user input for the rectangle
void getRectangleUserInput( float *width, float *height )
{
printf( "Enter the width and height of the rectangle: " );
scanf( "%f %f", width, height );
}
// Get the area of the rectangle
float calculateRectangleArea( float width, float height )
{
return width * height;
}
// Print the area of the rectangle
void printRectangleArea( const float area )
{
printf( "Rectangle Area: %.2f\n", area );
}
/*****************************************************************************/
// Ask user to select a shape
void getUserChoice( int *choice )
{
printf( "Select a shape to calculate its area:\n" );
printf( "Type '0' for Circle\n" );
printf( "Type '1' for Rectangle\n" );
scanf( "%d", choice );
}
/*****************************************************************************/
int main()
{
int errorCode = 0;
int choice;
getUserChoice( &choice );
if( choice == 0 )
{
float radius;
getCircleUserInput( &radius );
float area = calculateCircleArea( radius );
printCircleArea( area );
}
else if( choice == 1 )
{
float width;
float height;
getRectangleUserInput( &width, &height );
float area = calculateRectangleArea( width, height );
printRectangleArea( area );
}
else
{
printf( "Invalid choice.\n" );
errorCode = 1;
}
return errorCode;
}
The original code had the responsibilities of input, processing, and output intertwined within the same functions. In the refactored code, each concern has been clearly separated into distinct functions.
The compute_area_circle and compute_area_rectangle functions now solely focus on the calculation of the area for their respective shapes, while the responsibility of handling user input and output display has been delegated to the main function.
By decoupling these responsibilities, the code becomes more modular, maintainable, and easier to understand. Overall, the refactoring aligns well with the principles of SRP and promotes a cleaner, more organized codebase.
2. Open-Closed Principle (OCP):
Credit: https://dev.to/satansdeer/openclosed-principle-86a
Software entities (
classes,modules, functions, etc.) should be open for extension, but closed for modification.
The Open-Closed Principle suggests that a software module should be designed in a way that allows it to be extended without altering its existing codebase. An "open" module permits new functionalities to be added, while a "closed" module maintains a stable and well-defined interface for other modules to interact with.
The principle underscores the idea that changes in functionality should be achievable by extending a module rather than modifying its existing code. In practical terms, this implies that new features can be added and existing ones can be improved, without the risk of unintended side effects in the working parts of the system.
Example:
While our code is functional, it lacks the flexibility to seamlessly introduce new shapes without the need to modify existing functions. The API is fragmented, leading to the inclusion of an extra "else" condition within the main "if-else" statements every time we want to add a new shape. This is where the Open-Closed Principle comes to our rescue.
We chose to utilize the strategy design pattern to demonstrate the application of Open-Closed Principle. Let's refactor the code by introducing an abstract "ShapeProvider" interface. This interface encapsulates the shape functionality and sets the stage for extensibility.
Here's how:
#include <stdio.h>
#define PI 3.14159
/*****************************************************************************/
// Abstract strategy interface
typedef struct
{
char name[20];
void ( *getUserInput )( float *params );
float ( *calculateArea )( float *params );
void ( *printArea )( const float area );
} ShapeProvider;
/*****************************************************************************/
// Get user input for the circle
void getCircleUserInput( float *params )
{
float radius;
printf( "Enter the radius of the circle: " );
scanf( "%f", &radius );
params[0] = radius;
}
// Get the area of the circle
float calculateCircleArea( float *params )
{
float radius = params[0];
return PI * radius * radius;
}
// Print the area of the circle
void printCircleArea( const float area )
{
printf( "Circle Area: %.2f\n", area );
}
/*****************************************************************************/
// Get user input for the rectangle
void getRectangleUserInput( float *params )
{
float width;
float height;
printf( "Enter the width and height of the rectangle: " );
scanf( "%f %f", &width, &height );
params[0] = width;
params[1] = height;
}
// Get the area of the rectangle
float calculateRectangleArea( float *params )
{
float width = params[0];
float height = params[1];
return width * height;
}
// Print the area of the rectangle
void printRectangleArea( const float area )
{
printf( "Rectangle Area: %.2f\n", area );
}
/*****************************************************************************/
// Ask user to select a shape
void getUserChoice( int *choice, ShapeProvider *shapeProvider,
size_t numberOfShapes )
{
printf( "Select a shape to calculate its area:\n" );
for( size_t i = 0; i < numberOfShapes; i++ )
{
printf( "Type '%lu' for %s\n", i, shapeProvider[i].name );
}
scanf( "%d", choice );
}
/*****************************************************************************/
int main()
{
int errorCode = 0;
ShapeProvider shapeProvider[] =
{
{ "Circle", getCircleUserInput, calculateCircleArea, printCircleArea },
{ "Rectangle", getRectangleUserInput, calculateRectangleArea, printRectangleArea }
// New shapes can be added here....
};
const size_t numberOfShapes = sizeof( shapeProvider ) / sizeof(
shapeProvider[0] );
int choice;
getUserChoice( &choice, shapeProvider, numberOfShapes );
if( ( size_t )choice >= numberOfShapes )
{
printf( "Invalid choice.\n" );
return 1;
}
float params[2];
shapeProvider[choice].getUserInput( params );
float area = shapeProvider[choice].calculateArea( params );
shapeProvider[choice].printArea( area );
return errorCode;
}
In this refactored code, the main's "if-else" is not needed anymore and is thus removed from the codebase. The common API that "ShapeProvider" defines, serves as a contract for any future extensions. The circle and the rectangle-related functions serve the "ShapeProvider" contract by implementing its API. If we need a new shape it is just a matter of implementing new functions based on this contract.
By embracing the Open/Closed Principle, we've made the code more flexible. Adding new shapes is now a breeze. Existing code remains untouched, demonstrating the power of the OCP in maintaining a stable core while enabling easy extensions.
3. Liskov Substitution Principle (LSP):
Subtypes should be substitutable for their base types.
The Liskov Substitution Principle may sound like the Open-Closed Principle as they are two sides of the same coin. It is an extension of the Open/Closed Principle and states that it should always be possible to replace a type with a subtype. It ensures that new derived types are extending the base type without changing their behavior.
In C you don't often have subtypes, but you can apply the principle at the module level: code should be designed so that using an extended version of a module, like a newer version, should not break it. The code should be designed to ensure that new implementations can replace old ones without causing disruptions.
Example
With OCP we may have now a common API but to be LSP compliant we must do more. The Liskov Substitution Principle emphasizes that objects of a supertype should be replaceable with objects of its subtypes without affecting correctness. In C, this means adhering to consistent data structures and behavior.
Please note that to solve this problem we will implement polymorphism. We will use a little gem function from the Linux Kernel that is called container_of. This function will help us reference a parent class from the base class. More about this function in a future post.
The "ShapeProvider" API must be changed in a way that instances of any current or future shape can be substituted in the API. For this, we will rename "ShapeProvider" into "Shape" and make it serve as our base C-like class that will be used for the API. We will create a "struct" for each Shape (Circle, Rectangle) and make sure that we can substitute them for the base class.
Let's continue and make our code LSP compliant.
#include <stdio.h>
#include <stddef.h> // For offsetof
#include <stdlib.h> // For malloc and free
#define PI 3.14159
// TODO: More about this function in a future blog post
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
/*****************************************************************************/
// Abstract Shape interface
typedef struct Shape_
{
void ( *getUserInput )( struct Shape_ *shape );
float ( *calculateArea )( struct Shape_ *shape );
void ( *printArea )( const float area );
void ( *destructor )( struct Shape_* shape );
} Shape;
#define SHAPE_getUserInput(shape) ((shape)->getUserInput( shape ))
#define SHAPE_calculateArea(shape) ((shape)->calculateArea( shape ))
#define SHAPE_printArea(shape, area) ((shape)->printArea( area ))
#define SHAPE_destructor(shape) ((shape)->destructor( shape ))
/*****************************************************************************/
typedef struct
{
Shape base;
float radius;
} Circle;
// Get user input for the circle
static void circle_getUserInput( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
printf( "Enter the radius of the circle: " );
scanf( "%f", &circle->radius );
}
// Get the area of the circle
static float circle_calculateArea( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
return PI * circle->radius * circle->radius;
}
// Print the area of the circle
static void circle_printArea( const float area )
{
printf( "Circle Area: %.2f\n", area );
}
// Circle destructor
static void circle_destructor( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
free( circle );
shape = NULL;
}
// Circle Constructor
Shape *circle_ctor( void )
{
// As an optional step we can allocate this statically.
// It is left to the reader... or for a future blog post.
Circle *circle = ( Circle * )malloc( sizeof( Circle ) );
circle->base.getUserInput = circle_getUserInput;
circle->base.calculateArea = circle_calculateArea;
circle->base.printArea = circle_printArea;
circle->base.destructor = circle_destructor;
circle->radius = 0;
return &circle->base;
}
/*****************************************************************************/
typedef struct
{
Shape base;
float width;
float height;
} Rectangle;
// Get user input for the rectangle
static void rectangle_getUserInput( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
printf( "Enter the width and height of the rectangle: " );
scanf( "%f %f", &rectangle->width, &rectangle->height );
}
// Get the area of the rectangle
static float rectangle_calculateArea( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
return rectangle->width * rectangle->height;
}
// Print the area of the rectangle
static void rectangle_printArea( const float area )
{
printf( "Rectangle Area: %.2f\n", area );
}
// Rectangle destructor
static void rectangle_destructor( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
free( rectangle );
shape = NULL;
}
// Rectangle Constructor
Shape *rectangle_ctor( void )
{
// As an optional step we can allocate this statically.
// It is left to the reader... or for a future blog post.
Rectangle *rectangle = ( Rectangle * )malloc( sizeof( Rectangle ) );
rectangle->base.getUserInput = rectangle_getUserInput;
rectangle->base.calculateArea = rectangle_calculateArea;
rectangle->base.printArea = rectangle_printArea;
rectangle->base.destructor = rectangle_destructor;
rectangle->width = 0;
rectangle->height = 0;
return &rectangle->base;
}
/*****************************************************************************/
// Ask user to select a shape
void getUserChoice( int *choice )
{
printf( "Select a shape to calculate its area:\n" );
printf( "Type '0' for Circle\n" );
printf( "Type '1' for Rectangle\n" );
scanf( "%d", choice );
}
/*****************************************************************************/
int main()
{
int errorCode = 0;
int choice;
getUserChoice( &choice );
Shape *shape = NULL;
if( choice == 0 )
{
shape = circle_ctor();
}
else if( choice == 1 )
{
shape = rectangle_ctor();
}
else
{
printf( "Invalid choice.\n" );
return 1;
}
SHAPE_getUserInput( shape );
float area = SHAPE_calculateArea( shape );
SHAPE_printArea( shape, area );
SHAPE_destructor( shape );
return errorCode;
}
In this version, we have explicitly defined the Circle and Rectangle structures as derived types of the base Shape. Each derived type embeds the Shape structure to achieve a form of inheritance. The "calculateArea" functions are implemented in a way that adheres to the behavior of the base Shape's function pointer.
The usage of container_of hides many dangers. In C99 there is no type checking. Also, the base Shape should always be the first element in the subtype struct. We do not want to have undefined behavior in C.
The "circle_ctor" and "rectangle_ctor" functions dynamically allocate memory for the relative shape, setting all needed function pointers. This helps in achieving the concept of constructor-like behavior. For embedded systems, using dynamic memory allocation is considered a bad practice. There are ways to overcome this limitation by adapting the constructor to do static allocation. Or use a custom malloc function. More about it in a future post.
By creating a more structured hierarchy and ensuring that derived types conform to the behavior and contracts of the base type, this version of the code aligns more closely with the Liskov Substitution Principle.
4. Interface Segregation Principle (ISP):
Clients should not be forced to depend upon interfaces that they do not use.
The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they do not use. In C, this can be translated into using smaller, focused interfaces.
It is harmful to depend on modules that contain more than you need. This is true for source code dependencies that can force unnecessary recompilation and redeployment—but it is also true at a much higher, architectural level.
Example:
Our code is stored in a single C file. An extra step is to split the code into different submodules. In C this will make the depenencies between modules and the interfaces they use clearer. After the code is split into multiple files we might see opportunities for further refactoring. The following files make sense. Adding the interfaces we want to expose in the header files and the implementation in the C files.
- shape.[c/h]
- circle.[c/h]
- rectangle.[c/h]
- userInput.[c/h]
- main.c
This is left as an exercise for the reader.
Let's continue...
5. Dependency Inversion Principle (DIP):
Depend upon abstractions, not concretions
What this principle means is that high-level modules should not depend on low-level modules, but both should depend on abstractions. In C, this can be achieved by using function pointers. Then we can embed functionality on the shape without depending or forcing the usage of it.
Example:
The Shapes module currently exhibits a direct dependency on the code responsible for input and output operations. To address this, we will incorporate the "ShapeIOControllerProvider." Employing the principle of dependency inversion, we will proceed to decouple the InputOutput functionality from the Shapes module.
#include <stdio.h>
#include <stddef.h> // For offsetof
#include <stdlib.h> // For malloc and free
#include <stdarg.h> // For variadic function arguments
#include <math.h> // For fabs
#define PI 3.14159
// TODO: More about this function in a future blog post
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
/*****************************************************************************/
// Abstract Shape interface
typedef struct Shape_
{
float ( *calculateArea )( struct Shape_ *shape );
void ( *destructor )( struct Shape_* shape );
} Shape;
#define SHAPE_calculateArea(shape) ((shape)->calculateArea( shape ))
#define SHAPE_destructor(shape) ((shape)->destructor( shape ))
/*****************************************************************************/
typedef struct
{
Shape base;
float radius;
} Circle;
// Get the area of the circle
static float circle_calculateArea( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
return PI * circle->radius * circle->radius;
}
// Circle destructor
static void circle_destructor( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
free( circle );
shape = NULL;
}
// Circle Constructor
Shape *circle_ctor( void )
{
// As an optional step we can allocate this statically.
// It is left to the reader... or for a future blog post.
Circle *circle = ( Circle * )malloc( sizeof( Circle ) );
circle->base.calculateArea = circle_calculateArea;
circle->base.destructor = circle_destructor;
circle->radius = 0;
return &circle->base;
}
/*****************************************************************************/
typedef struct
{
Shape base;
float width;
float height;
} Rectangle;
// Get the area of the rectangle
static float rectangle_calculateArea( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
return rectangle->width * rectangle->height;
}
// Rectangle destructor
static void rectangle_destructor( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
free( rectangle );
shape = NULL;
}
// Rectangle Constructor
Shape *rectangle_ctor( void )
{
// As an optional step we can allocate this statically.
// It is left to the reader... or for a future blog post.
Rectangle *rectangle = ( Rectangle * )malloc( sizeof( Rectangle ) );
rectangle->base.calculateArea = rectangle_calculateArea;
rectangle->base.destructor = rectangle_destructor;
rectangle->width = 0;
rectangle->height = 0;
return &rectangle->base;
}
/*****************************************************************************/
// Dependency Inversion between Shape and IO
typedef struct
{
void( *inputFunc )( Shape *shape );
void( *outputFunc )( Shape *shape, ... );
} IOController;
typedef struct
{
Shape *shape;
IOController io;
} ShapeIOControllerProvider;
// Get user input for the shape
void getUserInput( ShapeIOControllerProvider *provider )
{
provider->io.inputFunc( provider->shape );
}
// Print the area of the rectangle
void print( ShapeIOControllerProvider *provider, const float area )
{
provider->io.outputFunc( provider->shape, area );
}
/*****************************************************************************/
// User interacts with Circle
static void user_input_circle( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
printf( "Enter the radius of the circle: " );
scanf( "%f", &circle->radius );
}
static void user_output_circle( Shape *shape, ... )
{
va_list args;
va_start( args, shape );
double area = va_arg( args, double );
printf( "Circle Area: %.2f\n", area );
va_end( args );
}
void createCircleIOControllerProvider( ShapeIOControllerProvider *provider )
{
provider->shape = circle_ctor();
provider->io.inputFunc = user_input_circle;
provider->io.outputFunc = user_output_circle;
}
/*****************************************************************************/
// Test interacts with Circle
static void test_input_circle( Shape *shape )
{
Circle *circle = container_of( shape, Circle, base );
circle->radius = 16;
printf( "Selected: Radius = %0.2f for testing\n", circle->radius );
}
static void test_output_circle( Shape *shape, ... )
{
va_list args;
va_start( args, shape );
Circle *circle = container_of( shape, Circle, base );
double area = va_arg( args, double );
printf( "----------------\n" );
printf( "Radius: %.2f\n", circle->radius );
printf( "Area: %.2f\n", area );
printf( "Circle Addr: %p\n", ( void * )circle );
printf( "Shape Addr: %p\n", ( void * )&circle->base );
double desired_area = 804.25f;
if( fabs( desired_area - area ) > 0.1f )
{
printf( "Test failed\n" );
}
else
{
printf( "Test passed\n" );
}
va_end( args );
}
void createMockCircleIOControllerProvider( ShapeIOControllerProvider *provider )
{
provider->shape = circle_ctor();
provider->io.inputFunc = test_input_circle;
provider->io.outputFunc = test_output_circle;
}
/*****************************************************************************/
// User interacts with Rectangle
static void user_input_rectangle( Shape *shape )
{
Rectangle *rectangle = container_of( shape, Rectangle, base );
printf( "Enter the width and height of the rectangle: " );
scanf( "%f %f", &rectangle->width, &rectangle->height );
}
static void user_output_rectangle( Shape *shape, ... )
{
va_list args;
va_start( args, shape );
double area = va_arg( args, double );
printf( "Rectangle Area: %.2f\n", area );
va_end( args );
}
void createRectangleIOControllerProvider( ShapeIOControllerProvider *provider )
{
provider->shape = rectangle_ctor();
provider->io.inputFunc = user_input_rectangle;
provider->io.outputFunc = user_output_rectangle;
}
/*****************************************************************************/
// Ask user to select a shape
void getUserChoice( int *choice )
{
printf( "Select a shape to calculate its area:\n" );
printf( "Type '0' for Circle\n" );
printf( "Type '1' for Rectangle\n" );
scanf( "%d", choice );
}
/*****************************************************************************/
int main()
{
int errorCode = 0;
int choice;
getUserChoice( &choice );
ShapeIOControllerProvider provider;
if( choice == 0 )
{
// createMockCircleIOControllerProvider( &provider );
createCircleIOControllerProvider( &provider );
}
else if( choice == 1 )
{
createRectangleIOControllerProvider( &provider );
}
else
{
printf( "Invalid choice.\n" );
return 1;
}
getUserInput( &provider );
float area = SHAPE_calculateArea( provider.shape );
print( &provider, area );
SHAPE_destructor( provider.shape );
return errorCode;
}
One cool thing to notice in this refactoring is that we can not write better tests for our code. We just have to create a mock Provider and use it instead of the default. See the commented out "createMockCircleIOControllerProvider".
Conclusion:
Our code is now SOLID compliant but there is more to it. The resulting code lacks Error Handling and Input Validation.
By adhering to the SOLID principles you can create well-structured and maintainable code in the C language. These principles provide a solid foundation for designing software that is easier to understand, modify, and extend over time. Remember, even in a language like C, applying these principles can lead to more robust and efficient codebases. We have seen that SOLID can be applied in C with extra care and some refactoring.
Sources:
- Clean Architecture
- SOLID Design for Embedded C
- Blog post for Single Responsibility Principle by Robert C. Martin
- Stackoverflow on Solid Principles in C
Footnotes
In C, modules are often implemented using header files (.h) and source files (.c). The header file contains function prototypes, type definitions and external variable declarations, while the source file contains the actual implementation of those functions and variables. ↩
An actor in the Unified Modeling Language (UML) is a term used to represent a role that interacts with a system. Actors are external entities that interact with the system to achieve certain goals. Actors can be individuals, other systems, or even external components that interact with the system you're modeling. ↩