How declarative paradigm allow to solve complex problem in a simple way
This article explores how declarative techniques, bolstered by functional libraries in C#, lead to clearer and more maintainable code, while also reflecting on the intuitive, yet sometimes burdensome, nature of imperative programming.
Embracing the Declarative Paradigm: A Journey from Lisp to SQL, C#, and Beyond
Back in the early days of programming - when the art of coding was as much about exploring new ways of thinking as it was about getting things to work. This journey began with the revolutionary ideas of Lisp, a language that redefined how we could treat code itself as data. This story isn’t just about syntax or commands; it’s about a paradigm shift that transformed my approach to problem-solving.
Have you ever thought that everything code does is essentially a data transformation? Whether it’s capturing a new order in a database - just a new record representing a transaction - or calculating a user’s age by subtracting their birth date from the current date, at its core, every operation is about converting one form of data into another. This perspective transforms our view of coding: every function, query, or process is simply a series of data transformations that turn raw inputs into meaningful outputs.
For instance, consider the task of converting temperatures from Celsius to Fahrenheit. An imperative approach might involve looping through each temperature and applying the conversion formula manually. In contrast, a declarative approach lets you express this transformation in a single, elegant pipeline:
var celsiusTemps = new List<double> { 0, 20, 37.5 };
var fahrenheitTemps = celsiusTemps.Select(temp => (temp * 9/5) + 32);
fahrenheitTemps.Iter(temp => Console.WriteLine($"{temp}°F"));
Another example is calculating the total cost of items in a shopping cart. Instead of writing verbose loops to multiply each item's price by its quantity and summing the results, you can declare the operation concisely:
var cartItems = new List<(decimal Price, int Quantity)> {
(9.99m, 2),
(15.50m, 1),
(3.75m, 4)
};
var totalCost = cartItems.Sum(item => item.Price * item.Quantity);
Console.WriteLine($"Total Cart Value: {totalCost}");
These examples illustrate how viewing code as a series of data transformations can simplify your logic and enhance clarity. By focusing on what needs to be achieved rather than how to do it, you not only write cleaner code - you also shift your mindset to embrace the power of declarative programming.
The Beginnings: Lisp and the Birth of Declarative Thought
Back in the late 1950s, when computers were colossal machines and programming was still an emerging craft, Lisp broke the mold. Unlike other languages of its time, Lisp introduced the idea that code could be treated as data - a concept that sparked a wave of innovation. Instead of getting bogged down in the minutiae of every procedural step, programmers could focus on the transformation itself - an approach that laid the foundation for declarative thinking.
Fast forward to modern times, and languages like Clojure have breathed new life into these early ideas. With a strong emphasis on immutability and functional programming, Clojure made it even easier to express what needed to be done, rather than how to do it. This fresh perspective resonated with me, setting the stage for my deeper exploration of declarative techniques, take a look at the snippet, no for-loops, no while's, etc:
;; Define a collection of customer maps
(def customers
[{:first-name "John" :last-name "Doe" :age 25}
{:first-name "Jane" :last-name "Smith" :age 35}
{:first-name "Alice" :last-name "Brown" :age 40}])
;; A function that processes the customers:
;; It filters those older than 30, sorts them by last name,
;; and then formats their names in "LastName, FirstName" style.
(defn process-customers [custs]
(->> custs
(filter #(> (:age %) 30))
(sort-by :last-name)
(map (fn [c] (str (:last-name c) ", " (:first-name c))))))
;; Print the transformed results
(doseq [name (process-customers customers)]
(println name))
In this example, notice how we state what we want - filtering by age, sorting by last name, and mapping to a string format - without manually handling the iterative steps. This concise expression of intent is what makes Clojure, and declarative programming in general, so powerful.
A Tale of Two Paradigms: Imperative vs. Declarative
Every programmer’s journey involves grappling with different ways of thinking. I often found myself torn between the granular control of imperative programming and the abstract elegance of declarative approaches.
Imperative: Telling the Machine Exactly How to Proceed
Imperative programming felt like directing an orchestra, where every instrument - every loop, every conditional -had to play its part perfectly. I learned that while this method offers meticulous control over each step, it can also lead to complex, error-prone code that becomes increasingly difficult to manage as projects grow. But, I must mention, that this approach becomes inevitable in for example system-level programming where you must work with bytes which are representation of data. You just can't avoid it because of the nature of the way a computer(machine for wider coverage) manages memory; we'll talk about it later.
Declarative: Painting with Broad Strokes
Then came the world of declarative programming - a realm where the focus shifts from the details of the journey to the destination itself. Languages like SQL, and tools like LINQ in C#, allowed me to simply state my intent. Rather than worrying about every intermediate step, I could specify the result I desired and let the system handle the mechanics. This not only reduced boilerplate code but also lightened the cognitive load, making my code clearer and more maintainable.
Imperative Programming: Precision, Intuition, and the Hidden Challenges
While declarative programming emphasizes describing what you want to achieve, the imperative approach exists because it gives you granular control over how your code operates. This fine-tuned management is essential in system programming - where developers need to handle memory allocation, manage hardware resources, and optimize performance at a low level. The explicit nature of imperative code allows for precise control, ensuring that each step of the execution process is meticulously managed.
Additionally, the imperative style often appeals to newcomers in coding because it mirrors how we naturally tackle complex tasks. Consider the process of building a house: you start by laying the foundation, then erect the framework, install the necessary utilities, and finally add the finishing touches. This sequential, step-by-step method is intuitive - each task follows logically from the previous one. For many beginners, this approach is straightforward and relatable, as it aligns with our innate way of organizing thoughts and actions in everyday life.
However, while imperative programming can be easier to write as an ad-hoc solution - allowing you to quickly piece together a working program - it tends to become a burden over time. The same explicit detail that provides control in small-scale scenarios often leads to tightly coupled and complex codebases in larger systems. Maintaining and extending such code requires significant effort, and refactoring becomes challenging. This inherent trade-off has driven the evolution of programming paradigms, pushing many developers toward declarative and functional approaches that prioritize clarity, maintainability, and scalability - I'm an example of such a person.
Functional Libraries in .NET: Enhancing Declarative and Functional Programming
The .NET ecosystem has traditionally been rooted in an imperative style. However, as software complexity grew and the benefits of immutability and function composition became clear, a demand for functional programming features emerged. This led to the creation of libraries like language-ext and OneOf.
- language-ext:
This library brings a rich set of functional programming constructs to .NET, including monads (such as Option
, Either
), immutable data structures, and functional pipelines. By encouraging immutability and side-effect-free functions, language-ext aligns closely with the declarative paradigm. It allows developers to write expressive, concise code that clearly conveys intent without managing low-level control flow.
- OneOf:
OneOf addresses a common challenge in C# - the lack of native support for union types. It provides a way to express a value that can be one of several types (for example, a successful result or an error), enabling more robust error handling and cleaner code. This kind of union type is common in functional languages and further encourages developers to think declaratively about control flow and data handling.
Parallels Between Functional Programming and Declarative Approaches:
Both functional programming and declarative programming share core principles:
- Abstraction: Focus on what needs to be done rather than the step-by-step process.
- Immutability: Reduce side effects by avoiding mutable state.
- Composability: Build complex logic by composing simpler functions or queries.
Libraries like language-ext and OneOf embody these principles, enabling a more declarative style within an imperative language like C#. They provide the tools to write code that is both expressive and easier to reason about, bridging the gap between functional and declarative paradigms.
The Declarative Advantage in Action
SQL has always been the poster child of declarative programming for me. When I write a SQL query, I’m not thinking about loops or conditional logic - I’m describing the data I need. It’s like sketching a blueprint and letting the engine of the database do the heavy lifting. Similarly, LINQ in C# empowers me to work declaratively within an imperative language, striking a balance between clarity and control.
But the evolution of declarative programming didn’t stop there. The growing complexity of software systems demanded even more robust tools - tools that could bring the best of functional programming into the .NET ecosystem. Enter libraries like language-ext and OneOf.
At its core, both functional and declarative programming share the same guiding principles:
- Abstraction: Focusing on what needs to be done, rather than how to do it.
- Immutability: Reducing side effects by avoiding mutable state.
- Composability: Building complex logic by stitching together simpler functions or queries.
These principles aren’t just theoretical - they manifest in the tools and techniques that have reshaped the way I write code.
Real-World Examples: Imperative, SQL, and Declarative (with Functional Enhancements)
Let's explore four real-world examples that illustrate solving problems using imperative code, SQL, and declarative code - enhanced with functional programming techniques from language-ext and OneOf.
Example 1: Filtering and Sorting Customers
Imperative Approach in C#:
// Define a Customer class
public class Customer {
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
// Sample data
List<Customer> customers = new List<Customer>{
new Customer { FirstName = "John", LastName = "Doe", Age = 25 },
new Customer { FirstName = "Jane", LastName = "Smith", Age = 35 },
new Customer { FirstName = "Alice", LastName = "Brown", Age = 40 }
};
// Imperative filtering and sorting
List<Customer> filteredCustomers = new List<Customer>();
foreach (var customer in customers)
{
if (customer.Age > 30)
{
filteredCustomers.Add(customer);
}
}
filteredCustomers.Sort((a, b) => a.LastName.CompareTo(b.LastName));
foreach (var customer in filteredCustomers)
{
Console.WriteLine($"{customer.LastName}, {customer.FirstName}");
}
SQL Approach:
Assume you have a Customers
table with columns FirstName
, LastName
, and Age
.
SELECT CONCAT(LastName, ', ', FirstName) AS FullName
FROM Customers
WHERE Age > 30
ORDER BY LastName;
See the difference? Less code, smaller cognitive load on reader. You simply state what needs to be done, that's it.
Let's reimplement the same code but in declarative way:
var result = customers
.Where(c => c.Age > 30)
.OrderBy(c => c.LastName)
.Select(c => $"{c.LastName}, {c.FirstName}");
foreach (var name in result)
{
Console.WriteLine(name);
}
Enhanced Declarative Approach with language-ext:
Using language-ext, you can leverage its functional extensions for a more expressive pipeline:
using LanguageExt;
using static LanguageExt.Prelude;
var customersList = List(
new Customer { FirstName = "John", LastName = "Doe", Age = 25 },
new Customer { FirstName = "Jane", LastName = "Smith", Age = 35 },
new Customer { FirstName = "Alice", LastName = "Brown", Age = 40 }
);
var resultFunctional = customersList
.Filter(c => c.Age > 30)
.OrderBy(c => c.LastName)
.Map(c => $"{c.LastName}, {c.FirstName}");
resultFunctional.Iter(Console.WriteLine);
The functional version using language-ext enhances readability by leveraging immutable collections and pipeline functions.
Example 2: Aggregating Sales Data by Product
Another project required aggregating sales by product. The imperative code, replete with dictionaries and loops, served its purpose but at the cost of readability.
Imperative Approach in C#:
public class Sale {
public int ProductId { get; set; }
public decimal Amount { get; set; }
}
List<Sale> sales = new List<Sale>{
new Sale { ProductId = 1, Amount = 100.00m },
new Sale { ProductId = 2, Amount = 150.00m },
new Sale { ProductId = 1, Amount = 200.00m }
};
Dictionary<int, decimal> salesByProduct = new Dictionary<int, decimal>();
foreach (var sale in sales)
{
if (salesByProduct.ContainsKey(sale.ProductId))
salesByProduct[sale.ProductId] += sale.Amount;
else
salesByProduct[sale.ProductId] = sale.Amount;
}
foreach (var kvp in salesByProduct)
{
Console.WriteLine($"Product {kvp.Key}: Total Sales = {kvp.Value}");
}
SQL Approach:
Assume a Sales
table with columns ProductId
and Amount
.
SELECT ProductId, SUM(Amount) AS TotalSales
FROM Sales
GROUP BY ProductId;
Declarative Approach with LINQ in C#:
var salesByProductDeclarative = sales
.GroupBy(s => s.ProductId)
.Select(g => new { ProductId = g.Key, TotalSales = g.Sum(s => s.Amount) });
foreach (var product in salesByProductDeclarative)
{
Console.WriteLine($"Product {product.ProductId}: Total Sales = {product.TotalSales}");
}
Enhanced Declarative Approach with language-ext:
using LanguageExt;
using static LanguageExt.Prelude;
var salesList = List(
new Sale { ProductId = 1, Amount = 100.00m },
new Sale { ProductId = 2, Amount = 150.00m },
new Sale { ProductId = 1, Amount = 200.00m }
);
var aggregatedSales = salesList
.GroupBy(s => s.ProductId)
.Map(g => new { ProductId = g.Key, TotalSales = g.Sum(s => s.Amount) });
aggregatedSales.Iter(p => Console.WriteLine($"Product {p.ProductId}: Total Sales = {p.TotalSales}"));
Embracing LINQ - and later, language-ext - made the aggregation process both elegant and robust:
The language-ext version provides a clear, immutable pipeline for grouping and aggregation.
Example 3: Joining Orders with Customers
One of my favorite transformations was when I had to join order data with customer records. Initially, nested loops made the process tedious. Shifting to a declarative approach using LINQ - and integrating OneOf for error handling - resulted in code that read almost like natural language.
Imperative Approach in C#:
public class Order
{
public int OrderId { get; set; }
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
}
public class Customer
{
public int CustomerId { get; set; }
public string Name { get; set; }
}
List<Order> orders = new List<Order>
{
new Order { OrderId = 1, CustomerId = 101, OrderDate = DateTime.Now.AddDays(-1) },
new Order { OrderId = 2, CustomerId = 102, OrderDate = DateTime.Now }
};
List<Customer> customersList = new List<Customer>
{
new Customer { CustomerId = 101, Name = "John Doe" },
new Customer { CustomerId = 102, Name = "Jane Smith" }
};
List<string> orderDetails = new List<string>();
foreach (var order in orders)
{
foreach (var customer in customersList)
{
if (order.CustomerId == customer.CustomerId)
{
orderDetails.Add($"Order {order.OrderId} by {customer.Name} on {order.OrderDate:d}");
}
}
}
foreach (var detail in orderDetails)
{
Console.WriteLine(detail);
}
SQL Approach:
Assume two tables: Orders
(with columns OrderId
, CustomerId
, OrderDate
) and Customers
(with columns CustomerId
and Name
).
SELECT o.OrderId, c.Name, o.OrderDate
FROM Orders o
JOIN Customers c ON o.CustomerId = c.CustomerId;
Declarative Approach with LINQ in C#:
var joinedData = from order in orders
join customer in customersList on order.CustomerId equals customer.CustomerId
select new { order.OrderId, customer.Name, order.OrderDate };
foreach (var item in joinedData)
{
Console.WriteLine($"Order {item.OrderId} by {item.Name} on {item.OrderDate:d}");
}
Example 4: Selecting Top Performing Employees
Even something as simple as ranking employees took on new life with declarative code. Ordering, filtering, and taking the top performers was transformed from a series of imperative steps into a succinct, expressive pipeline.
Imperative Approach in C#:
public class Employee {
public string Name { get; set; }
public int PerformanceScore { get; set; }
}
List<Employee> employees = new List<Employee>{
new Employee { Name = "Alice", PerformanceScore = 90 },
new Employee { Name = "Bob", PerformanceScore = 75 },
new Employee { Name = "Charlie", PerformanceScore = 85 },
new Employee { Name = "Diana", PerformanceScore = 95 }
};
employees.Sort((e1, e2) => e2.PerformanceScore.CompareTo(e1.PerformanceScore));
for (int i = 0; i < 3 && i < employees.Count; i++)
{
Console.WriteLine($"{employees[i].Name} - Score: {employees[i].PerformanceScore}");
}
SQL Approach:
Assume an Employees
table with columns Name
and PerformanceScore
.
SELECT Name, PerformanceScore
FROM Employees
ORDER BY PerformanceScore DESC
LIMIT 3; -- Use TOP 3 in SQL Server
Declarative Approach with LINQ in C#:
var employees = new List(
new Employee { Name = "Alice", PerformanceScore = 90 },
new Employee { Name = "Bob", PerformanceScore = 75 },
new Employee { Name = "Charlie", PerformanceScore = 85 },
new Employee { Name = "Diana", PerformanceScore = 95 }
);
var topEmployees = employees
.OrderByDescending(e => e.PerformanceScore)
.Take(3);
foreach (var employee in topEmployees)
{
Console.WriteLine($"{employee.Name} - Score: {employee.PerformanceScore}");
}
Conclusion: Embracing Declarative and Functional Programming
Looking back, the journey from Lisp to modern C# is more than a technical evolution - it’s a narrative of transformation. Embracing declarative and functional programming has taught me to write code that is cleaner, more expressive, and inherently aligned with the problems at hand. Whether you’re querying data with SQL or refining your backend logic with LINQ and functional libraries, the declarative approach offers a clarity that is both refreshing and powerful.
For DBAs, SQL’s declarative nature is second nature - they leverage set-based operations to optimize data retrieval. For backend developers, embracing declarative techniques - enhanced by functional programming constructs - can reduce boilerplate, foster robust error handling, and align code more closely with the problem domain.
So, as you embark on your own coding adventures, consider stepping away from the nitty-gritty of control flow. Instead, declare your intentions boldly, embrace the beauty of immutability, and let your code tell a story of clarity and purpose. In doing so, you’ll not only write better code - you’ll transform the very way you think about solving problems.
Embrace the shift. Transform your code. Experience the clarity and power of declarative and functional programming.