C# support for decoupling Data, state and Logic
A desirable 'functional' programming paradign (as opposed to OOP) is one in which there is clear separation between Data, State and Logic:
- Data represents 'values'. Data is immutable (cannot change once created) and has value semantics (for equality etc). Data may contain methods to return different representations (views) of itself (ie the decimal or fration part of a real number), however its methods cannot change itself or interact with States or Logic.
- State represents 'memory'. It is made of Data with clearly defined mechanisms for access and mutatation. It does not mutate other States or use any Logic. (Note that State is different from state/stateful/stateless with a lowercase 's' which are commonly used to mean 'with a value' etc)
- Logic represents 'behaviour'. Is is stateless ('pure') functionality that links input (from UI etc), Data and State(s) and is the only entity that can mutate State(s).
A good summary of the bebefit of a such a 'functional' approach vs OOP can be found in https://clojure.org/about/state (in Clojure Data is called value, State is called Identity and Logic is called functionality). OOP program will have objects of type 'Dog' that know their name and address and can 'GoHome()', while this design maybe useful for some scenarios it has major limitations in most common kind of software which deals with information and UI. Archiving/journaling/reasoning about state changes is difficult in OOP, as is refactoring/reusing logic and data as they are coupled together with the state. A 'functional' paradigm will have an immutable 'DogRecord' (Data) having name and address, a separate 'DogsArchive' (State) keeping the current dog records and a backlog (ie of address changes), and a 'DogController' (Logic) that can fetch a dog record from the database and change it's location on a map etc.
This is a vast topic but in short separating Data, State and Logic will give you programming superpowers.
C# was developed as an OOP language where data state and logic are strongly coupled in classes. This makes coding in such a 'functional' paradigm challenging:
- Creating immutable data with value semantics is challenging as C# Objects are by default mutable (though the addition of read-only properties is a good step) and correctly implementing value semantics is not trivial . Immutable containers were recently added to .NET but they are cumbersome to use and have reference semantics.
- Encapsulating a state with its access/mutation API is challenging though recent language additions can give good solutions.
- Stateless logic can be expressed by static classes and static functions
The purpose of the F package is to greatly simplify the creation of data (immutable objects with value semantics), and to provide a mechanism for creating, accessing and mutating states.
FData
Deriving FData
declares an object as Data - immutable with value semantics. Some core types (ie strings, Tuples etc) are FData even without directly deriving from the FData base.
An FData
type/object my contain non-public mutable members as long as it is publically immutable.
FRecord
Allow easy creation and mutation of FData
types (records).
F collections (FList, FSet, FDict, FQueue, FArray)
FData
versions of commonn containers with enhanced API.
FState
Encapsulate an FData
object so that the only way to modify it is through clearly defined access/mutation mechanisms. Two concrete implementations are provided - FLockedState
which provides thread safety by locking on mutation, and FJournaledLockedState
which also archive previous versions of the State.
FWrapper
Allow the easy creation of a new (FData
) type which encapsulates another (FData
) type.
FComposer
Allow the easy creation of a new (FData
) type which encapsulates another type which is not (FData
) itself.
Data:
public class Employee : FRecord<Employee> {
public string Name { get; }
public readonly int Age;
public FSet<string> Phones { get; }
public Employee(string name, int age, FSet<string> phones) => (this.Name, this.Age, this.Phones) = (name, age, phones);
}
Notes:
FSet
is an immutable hashset with value semantics and other additions.- Deriving from
FRecord
makesEmployee
anFData
(immutable with value semantics), that is it getsEquals
/==
/!=
andGetHashCode
that uses all it's members. These are generated using reflection and cached in delegates for efficiency. This means thatEmployee
can be ie stored in anFSet
or be itself a key in anFDict
. - Deriving
FRecord
also givesEmployee
aWith
method that allows easy creation of mutations (ieemp2 = emp1.With(x => x.Name, "newname");
).With
expressions are resolved using resolution and cached in delegates for efficiency. FRecord
will verify (in DEBUG mode) that all ofEmployee
's public fields/properties are publically readonly andFData
themselves.
State:
public static class Store {
public static readonly FLockedState<FDict<string, Employee>> Employees = FLockedState.Create(new FDict<string, Employee>());
}
Notes:
- Store holds the State of the program in this case. It is implemented as a static class with readonly
FState
fields. FLockedState
is a mutable State that locks itself before allowing mutation so that the only way to change it is threadsafe. It has three methods:Ref
locks and mutate,In
locks and allows readonly access, andVal
allows threadsafe readonly access of a possibly stale value. UsingRef
andIn
hides locking and eliminate multithreading issues where locking was forgotten. Using 'Val' whereever stale values can be tolerated prevents unecessary locking while preserving thready safety.VDict
is an immutable dictionary with value semantics and other additions.
Logic:
public static class EmployeeLogic {
public static void AddEmployee(Employee employee) {
Store.Employees.Ref((ref FDict<string, Employee> storeEmployees) => {
storeEmployees += (employee.Name, employee);
});
}
public static bool AddEmployeePhone(string name, string phone) {
return Store.Employees.Ref((ref FDict<string, Employee> storeEmployees) => {
var (ok, newStoreEmployees) = storeEmployees.With(name, x => x.Phones, phones => phones + phone);
if (ok) storeEmployees = newStoreEmployees; // change the State
return ok;
});
}
public static (bool, Employee) GetEmployee(string name) => Store.Employees.Val[name];
public static IEnumerable<string> GetEmployeePhones(string name) {
var (ok, employee) = Store.Employees.Val[name];
if (!ok) return Enumerable.Empty<string>();
return employee.Phones;
}
}
Notes:
- EmployeeLogic is Logic - a collection of static (pure) methods.
AddEmployee
usesRef
to acquire a reference access to mutate the employees dictionary State and add/set an employee. UsingRef
is the only way to changeStore.Employee
and becasue it is anFLockedState
this operation is threadsafe (a lock is acquired internally).- Note the use of
+=
to add a (key, value) to the dictionary.F
collections prefer operator overloading for adding/removing (in the same way that basicstring
does) as they are more suiltable for immutable types. AddEmployeePhone
similary uses aRef
to mutateStore.Employees
in a threadsafe way. It usesWith
to calculate and return a mutation of storeEmployees with a mutated Phones property, and assign it to back to the State.- Note the way success is returned in
ok
. Using C# Nullable reference types (#nullable enable
), gives a compiler warning if you try to accessstoreEmployees
without checking thatok
is true.F
uses this pattern for all collections boundary checks and does not throw exceptions in those cases. GetEmployee
andGetEmployeePhones
usesVal
to get access to the current value ofStore.Employees
. No lock is taken in this case so the result may be stale which is fine in this case. However the call is still threadsafe as the returned value (being anFData
) is immutable. This kind of threadsafe access to possibly stale values wherever possible adds great effiency.
Main:
public static void Main() {
var dave = new Employee("Dave", 30, new FSet<string>("123"));
var john = dave.With(x => x.Name, "John");
Console.WriteLine("dave RefrenceEquals john ? " + (Object.ReferenceEquals(dave, john) ? "true" : "false")); // false
Console.WriteLine("dave==john ? " + (dave==john ? "true" : "false")); // false
var john1 = dave.With(x => x.Name, "John");
Console.WriteLine("john RefrenceEquals john1 ? " + (Object.ReferenceEquals(john, john1) ? "true" : "false")); // false
Console.WriteLine("john==john1 ? " + (john == john1 ? "true" : "false")); // true
EmployeeLogic.AddEmployee(john);
var (ok, storeJohn) = EmployeeLogic.GetEmployee("John");
if (ok) {
Console.WriteLine("john RefrenceEquals storeJohn ? " + (Object.ReferenceEquals(john, storeJohn) ? "true" : "false")); // true
Console.WriteLine("john==storeJohn ? " + (john == storeJohn ? "true" : "false")); // true
}
EmployeeLogic.AddEmployeePhone(john.Name, "456");
var (ok1, storeJohn1) = EmployeeLogic.GetEmployee("John");
if (ok1) {
Console.WriteLine("storeJohn RefrenceEquals storeJohn1 ? " + (Object.ReferenceEquals(storeJohn, storeJohn1) ? "true" : "false")); // false
Console.WriteLine("storeJohn==storeJohn1 ? " + (storeJohn == storeJohn1 ? "true" : "false")); // false
}
var storeJohnPhones = EmployeeLogic.GetEmployeePhones(john.Name);
Console.WriteLine(string.Join(",", storeJohnPhones)); // 123, 456
}
Notes:
- Main is also Logic
- Main is threadsafe as all objects are immutable. Locking is only used where a State is mutated.