Software engineering primer for non software engineers

11 hours ago 1

With AI providing a route for people without software engineering backgrounds to produce code, I figured giving some context on the important parts of software engineering would be useful. Each topic in this article is one I’d expect a junior developer to be aware of, and I’d expect a senior developer to be deeply familiar with.

A note: I’ve simplified some concepts and some names.

Code is the fundamental part of all software engineering. It represents an idea, a flow, a behaviour.

Standardized ways of writing code so that the machine can run them are called programming languages. Programming languages define a syntax and grammar, which are then implemented by parsers, compilers, and interpreters. Programming languages allow a human to turn ideas, concepts, or thoughts into structured code that a machine can understand. Each language has a different mental model, and a different feel. As this is a direct form of creativity, many developers have strong feelings about the programming languages they use.

There are different families of related or derivative programming languages. Languages within the same family will share many of the same concepts, but may differ in implementation or syntax. Knowledge in one programming language in a family will usually greatly help a developer understand other languages in the same family. Programming language design is highly iterative, developing concepts from other languages.

The repeated use of certain pieces of code can be turned into logically distinct reusable code called functions. Depending on the language, they may also be referred to as methods or procedures. There are small differences in the definition of each, but regardless, they serve the same purpose - to describe a specific behaviour that operates using specific data. That behaviour can be reused in multiple places by running the function.

Functions may take arguments or parameters. Arguments allow data to be used from elsewhere in the program. A good function has a clear purpose, with a limited yet useful set of arguments. A function that serves too many purposes makes code harder to understand.

The structure of data informs the usage of the data. Types provide both humans and computers with information on the structure. The structure guides the implementation of functions. Types also enforce contracts between code, so that invalid or incorrect usage is caught during development rather than when the program is running. At a basic level, types ensure the data is of the correct family (numbers, strings, booleans). At an advanced level, known as type driven development, types provide restrictions on possible values of data so that there’s only a specific subset of values.

Every mainstream language has a form of types. The way that types work, known as the type system, often share common behaviours and features across a family of languages. However, the idiomatic usage varies greatly between languages. TypeScript, Python, Java, C, Rust, Haskell all have very different approaches to types, even with considerable overlap. The choice of language therefore impacts the structure of data, and therefore impacts the architecture of your program.

Data may have a known structure called a schema. A schema, much like types, ensures the properties of data for both human purposes and machine purposes. A human uses a schema to understand data, whereas a machine uses a schema to verify the data it receives is of the correct structure.

Lists and arrays provide a collection of related data in such a way that each item in the collection can be used or manipulated via iteration. Arrays are typically the most efficient collection storage with the lowest overhead, leading to their prevalence as a concept in most programming languages. In some languages, lists and arrays must contain elements of the same type. In others, the elements may have different types.

Maps provide a structure where a collection of elements can be accessed via a label very quickly. They are often used for data where there are named fields which the developer may not know when they are writing the code. Most languages have some type of map collection.

Data may be combined with functions that work specifically with that data. In object-oriented programming (OOP), objects are the main concept used to define behaviours and limit access to data. Objects have different implementations in different languages, while the purpose usually remains the same.

The structure and relationships of objects may be defined through a constructor called classes. Classes may have relations to other classes or types.

Not all languages have classes, though most have a construct that serves a similar purpose.

Reducing the cognitive load of code uses scope to limit where different code is used. Most languages have some distinction between the scope of data, functions, and types. Scope allows for different data to be referred to by the same name, or limit what developers have access to elsewhere in the program. Scope restrictions guide architecture, and reflect architecture. Local scope refers to items within a small block of code, such as a function. Global scope refers to items accessible throughout the entire program or file. The majority of items should be within the local scope of a function or class, with very few items in the global scope.

When considering performance optimization, items within the global scope are difficult to optimize as they must be accessible in more situations when compared to those in the local scope. This plays a factor when reducing the memory usage of an application, as garbage collection will cleanup data that no longer needs to be accessible.

Programming languages that focus on objects and classes are known as object-oriented programming languages.

Programming languages that focus on the use of functions belong to a paradigm of languages known as functional programming.

A value which cannot be changed is known as immutable. Immutable values allow a programming language or framework to apply optimizations, while also reducing unexpected behaviour due to unexpected changes of data. Some programming languages, like functional programming languages, may have every value stored as immutable data. Others may not have any immutable values.

Code is only part of engineering, with software engineers needing to know many adjacent topics.

As code allows for applications to scale to millions of users, it also provides a way to scale security exploits to millions of users.

Humans make errors. AI makes more errors. A good software engineer will acknowledge that they are not perfect, and take steps to reduce potential security attack surfaces, and use tools to automate security analysis.

To aid with debugging and optimization, applications should provide logs to developers. These logs typically include metadata about a user’s device, any actions taken, and communication between services. Logged errors help a developer identify the root cause of a problem.

Logging too much data can create privacy and security breaches. A typical example is logging passwords or accessing tokens in plaintext, therefore leaking them.

The security of the program applies to all the code that the program uses, not only the bespoke code written for the program. For frontend code, that includes libraries used in user-facing code and the build chain used (e.g esbuild). For backend code, that includes libraries, tools, and the operating system itself. Software engineers have a legal obligation to ensure that libraries are up to date to prevent security risks. As a result, using fewer, well-trusted libraries reduces the attack surface.

Different languages, frameworks, and libraries have different performance levels. Choosing the right tool for a problem is something a software engineering team will balance against what is enjoyable, and what they know. Performance is a trade-off to consider along with other reasons to use a specific tool - rarely is the fastest tool is the most used tool. When comparing tools, it’s important to compare apples to apples. Two different frameworks in two different languages are mostly irrelevant unless a team is willing to rewrite the codebase to a new language.

Even within the same language, a performance-optimal implementation of logic might be rejected in favour of a more readable implementation. A good software engineer will know when and where to apply performance optimisation. Before time is spent on optimising performance, gathering metrics on the runtime ensures that time is spent on the right parts.

Code written in high-level languages must be turned into a form that can be understood by a computer. There are two main forms: compilers and interpreters. A compiler will turn high-level code into machine-compatible code before the program code is run, whereas an interpreter will turn high-level code into machine-compatible code as the program is running.

It is easier for a compiler to apply type checks, syntax checks, and performance optimisations than for an interpreter as the entire program is understood (parsed) before the code has been run. However, interpreted code is typically faster to begin running as there is no step before running required to parse and generate code.

The differences between the two aren’t as significant in modern development as they used to be. However, the difference in mindset remains an influence in development. Interpreted languages like JavaScript or Python come with an expectation of a fast turnaround for development, whereas compiled languages like C or Rust come with an expectation of a fast runtime after the program has been compiled. As a result, interpreted languages tend to involve a faster loop testing out ideas, while compiled languages have a lot of surrounding tooling.

Re-usable code and data is often abstract. Instead of applying for a very specific set of values, an abstraction may vary in the values used. A function with arguments and a return value is a form of abstraction, as are types and classes.

A software engineer will need to balance code between abstraction and specificity. Abstract code comes with benefits such as simplify code reuse and relationships, but may introduce cognitive load and performance issues.

When code is repeated, it adds more places where bugs or changes must be implemented. To avoid tech debt, a developer will seek to replace duplicated code with one, more abstract piece of code. This is known as DRY - don’t repeat yourself.

The design of technical systems is known as the architecture. Interactions between different services or code are guided to the architecture of the system. At the highest level, the architecture might be represented by diagrams or specifications. The architecture of a corporate codebase is often a reflection of the organisation (Conway’s law). However the architecture of open source codebases often reflect the personal preferences of the contributors, as libraries reflect the needs of many different corporate or non-corporate entities.

A software engineer’s workflow is vital to their success. A messy workflow often results in messy engineering.

The process of identifying and fixing bugs is known as debugging. Debugging is a skill built up through familiarity with the tools used (languages, frameworks, libraries) and the project itself. There are many tools for debugging, often language or framework specific. The concepts of debugging are roughly the same across all tools however.

A software engineer should be able to both recall bugs they’ve encountered before (and sometimes avoid repeating them), and find new bugs they haven’t encountered before. While senior developers might have a larger set of bugs they’ve encountered, junior developers can still be highly skilled debuggers through adaptive learning.

Automated reproduction of behaviour and validation of the results are known as tests. Testing too little reduces the confidence a developer may have in changes they made. Testing too much slows down development. A collection of tests are known as a test suite. Good test suites are run frequently, covering enough of the behaviour of the program that possible unintended changes or implications of behaviours are caught. If a test suite is too large, it will take a long time to run. If it takes a long time to run, developers will run the tests less often - reducing the value of the test suite.

Different types of tests are used for different scenarios. Tests which only test a specific function in isolation are known as unit tests. Tests which test an entire program are known as end-to-end tests. Tests which test a specific interaction between two elements of a system are known as integration tests. Each form of test has a well-suited purpose.

Unit tests could be used to verify one part of an algorithmic calculation. End-to-end tests could be used to ensure that a mobile app behaves as intended. Integration tests could be used to verify parts of a complicated system. A large production codebase normally needs a mixture of different types of tests for different parts of the system.

Every change a developer makes must be stored in a way that allows easy understanding of changes. A patch describes the changes to some code at a specific point in time. A patch will be attached to a commit which contains metadata about the patch, such as a description by the developer. Multiple commits may be combined to form a pull request which describes some set of feature changes, including bug fixes. A pull request is typically reviewed before it is added to a codebase by a merge. A pull request review provides feedback for the developer, often with changes requested to address an issue with the proposed patches.

The history of a codebase is represented through the commits, aiding developers in understanding when and why things were changed. Understanding the context of changes allows developers to fix or prevent bugs with greater ease.

git is the most widely used form of source control, though many other forms exist.

Unlike physical commodities, a critically significant portion of code is made freely available. This is known as open source - I.e, the source code is openly available. There are four fundamental freedoms granted by open source: the right to access code, and the right to use code, the right to modify code, and the right to redistribute modified code.

There are many forms of licenses that apply these freedoms to the code of a project to varying extent. The most common license families are BSD, GPL, Apache, and MIT. Different communities will default to a particular license. The license chosen has a significant impact on the potential users of a library, as some licenses impact the copyright of associated code.

Without open source, the rapid growth of the digital era would not be possible. Even more relevant to the reader, without open source, LLMs would not have been able to train on enough data to be useful coding assistants.

The majority of open source is hosted on Github, which provides a platform for managing source code versioning. Other platforms exist, however Github achieved market dominance several years ago. Open source projects have trouble with funding. For the value they provide to society, they are severely underfunded. Licenses of Github Copilot do not provide any compensation to the open source projects the LLM models are based on.

The choices made to implement a feature have an impact on the rest of the features you later implement. The accumulation of these choices is known as tech debt. Tech debt slows down feature development, and introduces bugs. Tech debt has many forms, including code for old features which are no longer used, data which is no longer accurate, or systems that have not been updated.

The process of two software engineers working together synchronously is called pair programming. Pair programming has many different forms. Pair programming aids knowledge transfer between two software engineers, often acting as a form of review in real time. Larger groups of software engineers are known as mob programming. Mob programming requires good coordination and facilitation to be productive.

When a change is made, it must go through verification that the changes are valid. The verification is usually some combination of automated tests and manual tests. Different industries have different forms of verification. Once the changes have been verified, they are merged to the codebase.

When changes are merged to the production codebase, they need to be deployed to the user. Products that involve some input from the user to update (e.g operating systems) often deploy their changes to production on a regular basis. Web applications, where the user does not need to update anything themselves, are typically deployed as soon as the changes are merged through continuous deployment. Continuous deployment keeps application up to date without any additional overhead for the developer when compared to a release management.

If a change is found to have a bug, there are three options: 1) fix the change and deploy the fix, 2) acknowledge the bug to be fixed later, or 3) rollback the current deploy to the previous deploy.

Small bugs with known fixes are often fixed and deploys. Bugs that don’t impact the overall user experience can wait. Bugs that will take longer to fix and have a critical impact should be rolled back. Rollbacks require that the previous code is able to run correctly, which may not always be possible. For example, if the broken latest commit modified data to a new schema, the data must be modified back to the previous schema before the previous code is able to run correctly.

This post will be a living document, and I’ll revisit sections in the future.

Share

Read Entire Article