Architecture

Golden Rules

Eigener Ansatz:

  1. Prototyp bauen (max. 1-3 Monate) mit Fokus auf bekannte Anforderungen und Funktionen (nicht zu weit in die Zukunft schauen, zu viele Dinge annehmen. Abstraktionen minimal halten!)
  2. Währenddessen: Requirements und Annahmen prüfen und anpassen
  3. Prototyp wegwerfen
  4. Software komplett neu schreiben (mit Learnings aus Prototyp)
  5. Alle 2-3 Jahre (sofern aktiv an Software gearbeitet wird und sich Anforderungen ändern/hinzukommen): Technisches Brainstorming mit Senior Ingenieuren "Greenfield Ansatz: Wie würden wir die Software nach heutigem Stand bauen" -> Mit Realität abgleichen und ggf. Refactoring einleiten

Separation of Concerns VS Locality of Behavior

Globaler State / Nebeneffekte

Duplikation von Funktionalität

KISS / Abstraktionen

All Problems in computer science can be solved by another level of indirection, except for the problem of too many layers of indirection.

https://nitter.net/transmutrix/status/1750563200708309466#m

DRY vs inline code

https://htmx.org/essays/codin-dirty/
https://www.youtube.com/watch?v=hQJcGmWXDJw

https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction

  1. Abstraktion wird eingeführt, um gleiche Funktion perfekt zu bündeln
  2. Neue Anforderung passt nur zu 90% in dieses Konzept
  3. Falsch: Anpassen der Abstraktion (mit zusätzlichen Parametern, if/else, etc.). -> wird noch mehrere Male wiederholt und führt zu versteckter Komplexität und unklarem Code mit Überraschungen
  4. Besser: Abstraktion rückgängig machen (code inline kopieren), notwendige Änderungen für neue Funktion durchführen, anschließend schauen, ob neue Abstraktion abgeleitet werden kann. -> sauber und nachhaltig

John Carmack on Inlined Code:

If a function is only called from a single place, consider inlining it.

If a function is called from multiple places, see if it is possible to arrange for the work to be done in a single place, perhaps with flags, and inline that.

If there are multiple versions of a function, consider making a single function with more, possibly defaulted, parameters.

If the work is close to purely functional, with few references to global state, try to make it completely functional.

Try to use const on both parameters and functions when the function really must be used in multiple places.

Minimize control flow complexity and “area under ifs”, favoring consistent execution paths and times over “optimally” avoiding unnecessary work.

API Design

Immer den Anwendungscode zuerst schreiben oder zumindest ein Mock-Up davon. Dies offenbart die notwendigen API Funktionen, ihre Parameter, Call-Order, etc.
Erst anschließend die Implementierung dieser Funktionen schreiben.
-> Empfehlung von Casey Muratori
https://caseymuratori.com/blog_0024

Keine globalen Variablen in Library (mutable global state) -> nicht thread-safe und re-entrant.
Besser: Kontext struct anlegen, welcher der API mitgegeben wird und von ihr mit dem aktuellen Zustand beschrieben wird.
Bei Callbacks: Thread Local Storage benutzen
Global State: a Tale of Two Bad C APIs (nullprogram.com)
siehe auch (als ein Ansatz in C): C#Opaque struct

Konstanten immer public definieren (entweder als enum oder define). -> Keine magic values im user-code. Macht Code deutlich lesbarer und durchsuchbarer (Konstanten findet man einfach, eine "1" ist nicht eindeutig identifizierbar). Kann einfach geändert und erweitert werden ohne, dass user-code vorm nächsten Kompilieren geändert werden muss.
https://caseymuratori.com/blog_0025

(CLI) Tools sollten mit Input aus Datei oder stdin gleich funktionieren. So kann man sie einfacher in eine Pipeline integrieren. Auch das Testen wird einfacher.
https://youtu.be/isI1c0eGSZ0?feature=shared&t=300

malloc()-Calls in Library vermeiden. Besser: Memory-Allokation vom Benutzer erledigen lassen. Dann kann der Anwender auswählen, ob er auf dem Stack oder Heap allokieren will oder sogar einen eigenen Allocator benutzen.

prepped_alloc PrepThingeeForAlloc(parameters)
{
    prepped_alloc Prep = BeginAllocPrep();
    //... bunch of macro calls here that describe all the allocations
    //... and their internal pointers to itself
    EndAllocPrep(Prep);
}

size_t GetSizeForThingee(parameters)
{
    prepped_alloc Prep = PrepThingeeForAlloc(parameters);
    size_t Result = GetSize(Prep);
    return(Result);
}

thingee *CreateThingee(void *Memory, parameters)
{
    prepped_alloc Prep = PrepThingeeForAlloc(parameters);
    thingee *Result = (thingee *)PlaceAlloc(Prep, Memory);
    return(Result);
}

https://hero.handmade.network/forums/code-discussion/t/763-stack_persistence_vs._api_simplicity#4335
https://stackoverflow.com/questions/59547761/should-functions-that-create-structs-return-the-struct-or-a-pointer
Kleine Strukturen können auch direkt auf dem Stack erzeugt werden und als struct (nicht als Pointer) zurückgegeben werden.
siehe auch: Stack vs. Heap

Premature Optimization

The full version of the quote is "We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil." and I agree with this. Its usually not worth spending a lot of time micro-optimizing code [i.e. counting cycles] before its obvious where the performance bottlenecks are. But, conversely, when designing software at a system level, performance issues should always be considered from the beginning. A good software developer will do this automatically, having developed a feel for where performance issues will cause problems. An inexperienced developer will not bother, misguidedly believing that a bit of fine tuning at a later stage will fix any problems.
-- Charles Cook

Observation #6: Software engineers have been led to believe that their time is more valuable than CPU time; therefore, wasting CPU cycles in order to reduce development time is always a win. They've forgotten, however, that the application users' time is more valuable than their time.

Quelle: The Fallacy of Premature Optimization (acm.org)

Performance lässt sich nicht magisch am Ende der Entwicklung herbeiführen. Insbesondere bei komplexen Systemen sind die Bottlenecks über das System verteilt, voneinander abhängig und bedingen Änderungen an der Architektur, welche am Ende sehr kostspielig und aufwendig sind.
Cmuratori zu diesem Thema: https://youtu.be/drCnFueS4og?feature=shared&t=5359

Parallelisierung

Mutex / Semaphore vermeiden! Insbesondere bei steigender Komplexität (und vielen Locks) kann nicht mehr gut abgeschätzt werden, wie häufig Threads in Mutex-locks laufen und wie viel wirklich parallel gearbeitet wird.
Besser: API Design, welche unabhängig von Parallelität ist. Beispiel: Schreiben einer Datei in parallelen chunks: Anstatt fertige Chunks in Array zu pushen oder in Datei zu schreiben (POSIX write(), Reihenfolge abhängig von Parallelität) -> Thread mit offset Parameter starten und fertige Chunks mit Offset in bereits allokierten Speicher oder Datei schreiben (POSIX pwrite(), Reihenfolge unabhängig von Zeitpunkt der Fertigstellung)

Quelle: OpenMP and pwrite() (nullprogram.com)

Protobufs: Daten zwischen Services mit Typeninformationen teilen

-> Protobufs Protocol Buffers Documentation (protobuf.dev)

Binärdaten

Step 1: Platz für Versionsfeld reservieren
Dies gilt für alle Binärdaten, eigene Datenformate oder Protokolle (Learning von: Savegame in diversen Spielen, Goodnightlamp Update Protokoll)

Funktionen

OOP vs Data-Oriented

cmuratori-discussion/cleancodeqa.md at main · unclebob/cmuratori-discussion
cmuratori-discussion/cleancodeqa-2.md at main · unclebob/cmuratori-discussion
https://www.rfleury.com/p/programmers-are-users-bad-performance
OOP Ansatz:

#include "raw_device.h"
class new_device : public raw_device {
public: 
	virtual file* open(char* name);
	virtual void close(file* f);
	virtual void read(file* f, size_t n, char* buf);
	virtual void write(file* f, size_t n, char* buf);
	virtual void seek(file* f, int n);
	virtual void get_name();
}

Data-oriented Ansatz (Typenimplementierung siehe 2. Link oben):

void raw_device::Handler(raw_device_request *Packet, raw_device_result *Result)
{
	switch(Packet->Op)
	{
		case RIO_read:
		// etc.
		
		case RIO_write:
		// etc.
		
		case RIO_get_name:
		// etc.
		
		default:
		// write error Result
	}
}

Vorteile des "Data-oriented Ansatzes":

OOP
f1 call ------ f1 handler ------ f1 code
f2      ------ f2         ------ f2
f3      ------ f3         ------ f3
Data oriented
f1 call \                       / f1 code
f2      ------ fx handler ------- f2 code
f3      /                       \ f3 code
-> the more handler/passthrough layers you have, the more code you have to write/maintin in the OOP case

DO NOT DO:

#include "devids.h"
#include "console.h"
#include "paper_tape.h"
#include "..."
#include "..."
void read(file* f, char* buf, int n) {
	switch(f->id) {
		case CONSOLE: read_console(f, buf, n); break;
		case PAPER_TAPE_READER: read_paper_tape(f, buf n);
			break;
		case...
		case...
	}
}

Good code / bad code

Cost of Dependencies

https://nitter.tiekoetter.com/cmuratori/status/1426299131270615040#m

https://nitter.tiekoetter.com/Jonathan_Blow/status/1923414922484232404#m