返回资讯中心
外部精选
软件工程
#开发者实时流

The Right Way to Write Complex Code

Complex software needs a coherent data model: one authoritative representation of each fact, explicit relationships between objects, complete construction and destruction, and a shared vocabulary that algorithms can use…

Hacker News 最新pykello2026/10/28 · 今天30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

Bill Cox

Working paper — publication draft

Complex software needs a coherent data model: one authoritative representation of each fact, explicit relationships between objects, complete construction and destruction, and a shared vocabulary that algorithms can use without depending on one another's implementations. I used these principles in electronic-design-automation software for decades before language models could write code. They are rules for human programmers first and AI collaborators second.

This paper traces the experiences behind those rules and the ways programming languages help or obstruct them. My criticisms concern exposed memory representation in C, inheritance-centered modeling in C++, Go's coupling of methods to the packages that declare their receiver types, and Rust's machinery for mutable graphs with back-pointers and multiple parents. The issue is not whether a sufficiently determined programmer can implement a graph in each language. The issue is how directly the language expresses the architecture, and how much effort is spent accommodating the language instead of solving the application problem.

AI adds a practical distinction: supervised pair programming is not the same task as autonomous execution of an agentic codebook. I use Go successfully with continuous expert supervision. That does not establish its suitability for an AI working without that supervision. For the latter task, I now prefer C#: ordinary object references, garbage collection, compile-time partial declarations and implementations, and mature libraries let us express the intended architecture with less machinery. C# is not perfect. It is a pragmatic implementation choice for an architecture that came first.

This is an engineering position paper based on my experience, architectural reasoning, and the project episode described below. It is not a controlled benchmark of programming languages or coding agents. Prepared with AI assistance from my earlier dictated account and subsequent clarifications; the first-person narrative is edited prose, not a verbatim transcript. Sources and evidence limits appear at the end.

These are rules for human programmers first. I enforced these rules, and others beyond the scope of this document, for 25 years, with excellent results. They guided the design, implementation, and review of complex systems long before LLMs could write code. Applying them to an AI collaborator is a later use of an established engineering discipline, not the reason that discipline exists.

I start with four recurring architectural problems. I call them bugs because of the damage they do to complex programs, not because they are necessarily mistakes in a compiler implementation.

These are my architectural criticisms. They are not claims that C cannot implement a structure of arrays, that every C++ program must use inheritance, or that a graph is impossible in Go or Rust. The problem is the design pressure: what is easy to express, what becomes conventional, and what programmers—and now language models—keep doing by default.

This document concerns large, maintainable systems running on one computer, such as an electronic-design-automation tool or Ensemble. It is not a prescription for distributed systems.

In the 1960s, memory was scarce and expensive. Programmers worked close to the machine, often in assembly. Modern cache hierarchies and the workloads that expose their costs were not the setting in which those early conventions developed.

C emerged around the turn of the following decade. Dennis Ritchie dates its development to 1969–1973, with its most creative period in 1972.[1] It gave systems programmers a practical way to move beyond machine-specific assembly while retaining control over memory and efficient execution. That was an enormous achievement.

But useful decisions can become permanent assumptions. A C structure has a concrete representation that other code can observe and depend on. Once that representation crosses a binary interface, changing it is no longer just an internal implementation decision. Compatibility spreads the convention across languages and generations of software.

That is why I sometimes call the C linker the root of all evil. The complaint is about the representation contract carried through C-compatible binary interfaces: a choice about how bytes are arranged becomes a constraint on future designs. It is not that the linker itself prevents every alternative layout. We implemented an alternative in C. We could do so because we had kept access to the data abstract.

At the same time, database research was teaching important lessons about representing information and avoiding redundancy. I took a central lesson from relational normalization, including third normal form: do not keep independently maintained copies of the same fact. That is the architectural lesson I apply here, rather than a formal definition of third normal form.

I have also used the phrase “Lisp bugs” for null-like values, memory reclamation, and structures that provide a way down from a parent without providing a way back from a child.

These are related concerns, but they are not the same problem. Nullable types can make absence explicit. Garbage collection can reclaim unreachable memory. Neither tells a child how to remove itself from both of its parents, or how to find the application’s logger.

The question that matters for this architecture is:

Who tells every parent when a child is deleted?

If the answer is “some other part of the program will remember,” the data model is incomplete. We knew how to represent and maintain these relationships long before today’s languages and frameworks. A memory manager does not remove the need to do so.

I regard explicit treatment of nullable values as an important response to the null problem. It is not the main subject here. The harder issue for these systems is maintaining the relationships between objects throughout their useful lives.

Early in my career, I worked on algorithms for placing and routing designs in field-programmable gate arrays. At QuickLogic, we hired a programmer from Cadence who brought an architecture that served us extraordinarily well.

We called its shared data model the database. It was not a relational database server. It was the common vocabulary of the system: the objects that all the algorithms manipulated, the facts about them, and the relationships between them. Today I call that module Common.

If data had to survive a particular module’s execution, or had to be understood by more than one module, its representation belonged in that shared model. A router and a timing analyzer did not invent different authoritative versions of the same wire.

The most important document for a new programmer described those data structures. Once the programmer understood them, they understood the vocabulary of the whole system.

Each functional module depended on the common definitions. Functional modules did not import and depend directly on one another. A top-level program—what we called main, whether it supplied a GUI or a command-line interface—orchestrated the modules.

main / application composition ├── placement module ──→ common ├── routing module ────→ common ├── timing module ─────→ common └── other modules ─────→ common

The arrows show code dependencies, not ownership relationships between runtime objects. common does not import the functional modules. The functional modules share a vocabulary without acquiring a web of package dependencies.

That separation let us concentrate on algorithms. From about 1990 to 1995, I could work principally on algorithms rather than repeatedly redesigning the underlying structures.

A graph gives a simple example. A node has incoming and outgoing edges. Each edge knows its source node and its destination node. Those are two distinct parent relationships. Constructing an edge takes both parents and establishes both memberships.

There is nothing exceptional about multiple parents. Complex systems contain them everywhere. One authoritative representation of a fact does not mean one owner for every object.

For a relationship-bearing object, the rule is:

If A points to B as part of a relationship, B has the corresponding reverse relationship to A.

Do not soften this to “use back-pointers where appropriate.” Within the shared relationship model, the reverse relationship is part of the model, not an optional optimization. The explicit value-object distinction below determines which objects are governed by this rule.

An edge constructor receives its source and destination nodes and joins the appropriate collections. Its destructor removes the edge from both collections.

A node destructor destroys its incoming and outgoing edges as required by the model, then removes the node from its owning graph. Shared traversal paths must not cause the same edge to be destroyed twice. Destruction must leave no parent retaining a relationship to an object that has been logically removed.

This is not a demand for a single-owner hierarchy. An object knows all of its parents. It detaches from all of them, and it destroys strongly owned children according to the meaning of those relationships.

The responsibility is divided deliberately:

The child must not reach into arbitrary parent collections and edit them itself. Equally, it must not leave unrelated code to discover and repair those relationships later.

In a garbage-collected language such as Go or C#, logical destruction is distinct from reclaiming the object’s storage. Garbage collection can recover memory when objects become unreachable; it does not perform these application-level relationship operations for us. In this paper, “destructor” means an explicit logical destruction operation, not a C# finalizer.

Even an object that is never destroyed needs its parent relationships.

Suppose a function receives only an object and encounters a condition worth logging. How does it find the logger? It should be able to navigate through the object’s actual parent relationships to the application context and the relevant operation.

It should not need a copied logger field, a global lookup, a callback bundle, or a new context argument threaded through every intervening function merely because the relationships are missing.

I am not claiming that every global in every program is forbidden; I have used globals myself. But I require programmers, human or AI, to correct missing relationships rather than use globals as the default workaround for an object’s missing context.

When legitimate access is difficult, inspect the data model and its declared operations first. Adding plumbing to the caller can conceal a mistake several levels upstream.

Not every reference represents a relationship that must be maintained in both directions.

I also use value objects: vectors, matrices, symbols, and similar values. I do not necessarily expect such a value to know every object that refers to it. In Lyric I can manage those values with reference counting; in Go or C# I can rely on garbage collection.

Every value-object type must be explicitly identified as such in a comment. The exception must be visible in the model, not inferred later because reverse relationships turned out to be inconvenient.

Some types need a deliberate decision. A compiler expression is a good example. Expressions form trees, but statements and other structures also refer to them. We can model expressions as relationship-bearing objects with back-pointers and explicit destruction, or explicitly choose value-object treatment with managed reclamation. The choice is not always obvious, but we must make it consistently.

The value-object exception does not excuse missing reverse relationships for the rest of the shared model. Nor does a type become a value object merely because the language happens to use garbage collection.

There is a separate, narro

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →