I've been digging into OOP day and night for over two years now. Read a thick stack of books, spent man months refactoring code from procedural to object-oriented and back again. A friend says that I have earned OOP of the brain. But do I have confidence that I can solve complex problems and write clear code?
I envy people who can confidently push their delusional opinion. Especially when it comes to development, architecture. In general, what I am passionately striving for, but what I have endless doubts about. Because I'm not a genius, and I'm not an FP, I have no success story. But let me put in 5 kopecks.
Encapsulation, polymorphism, object thinking ...?
Do you like it when you are loaded with terms? I've read enough, but the words above still don't tell me anything in particular. I'm used to explaining things in a language I understand. A level of abstraction, if you will. And for a long time I wanted to know the answer to a simple question: "What does OOP give?" Preferably with code examples. And today I will try to answer it myself. But first, a little abstraction.
Complexity of the task
The developer is one way or another engaged in solving problems. Each task has many details. Starting from the specifics of the API of interaction with the computer, ending with the details of the business logic.
The other day I collected a mosaic with my daughter. We used to collect jigsaw puzzles of a large size, literally from 9 parts. And now she can handle small mosaics for children from 3 years old. It is interesting! How the brain finds its place among the scattered puzzles. And what determines the complexity?
Judging by the mosaics for children, the complexity is primarily determined by the number of details. I'm not sure the puzzle analogy will cover the entire development process. But what else can you compare the birth of an algorithm at the time of writing a function body? And it seems to me that reducing the amount of detail is one of the most significant simplifications.
In order to more clearly show the main feature of OOP, let's talk about tasks, the number of parts of which does not allow us to assemble a puzzle in a reasonable time. In such cases, we need decomposition.
Decomposition
As you know from school, a complex problem can be broken down into simpler problems in order to solve them separately. The essence of the approach is to limit the number of parts.
It just so happens that while learning to program, we get used to working with a procedural approach. When there is a piece of data at the input, which we transform, we throw it into subfunctions, and map it to result. And ultimately, we decompose during refactoring when the solution is already there.
What's the problem with procedural decomposition? Out of habit, we need initial data, and preferably with a finally formed structure. Moreover, the larger the task, the more complex the structure of these initial data, the more details you need to keep in mind. But how to be sure that there will be enough initial data to solve subtasks, and at the same time get rid of the sum of all the details at the top level?
Let's look at an example. Not so long ago I wrote a script that makes assemblies of projects and throws them into the necessary folders.
interface BuildConfig {
id: string;
deployPath: string;
options: BuildOptions;
// ...
}
interface TestService {
runTests(buildConfigs: BuildConfig[]): Promise<void>;
}
interface DeployService {
publish(buildConfigs: BuildConfig[]): Promise<void>;
}
class Builder {
constructor(
private testService: TestService,
private deployService: DeployService
) // ...
{}
async build(buildConfigs: BuildConfig[]): Promise<void> {
await this.testService.runTests(buildConfigs);
await this.build(buildConfigs);
await this.deployService.publish(buildConfigs);
// ...
}
// ...
}
It might seem like I have applied OOP in this solution. You can replace service implementations, you can even test something. But in fact, this is a prime example of a procedural approach.
Take a look at the BuildConfig interface. This is a structure that I created at the very beginning of writing the code. I realized in advance that I could not foresee all the parameters in advance, and simply added fields to this structure as needed. By the middle of the work, the config was overgrown with a bunch of fields that were used in different parts of the system. I was annoyed by the presence of an "object" that needs to be finished with each change. It is difficult to navigate in it, and it is easy to break something by confusing the names of the fields. And yet, all parts of the build system depend on BuildConfig. Since this task is not so voluminous and critical, there was no disaster. But it is clear that if the system were more complicated, I would have screwed up the project.
An object
The main problem of the procedural approach is data, their structure and quantity. The complex data structure introduces details that make the task difficult to understand. Now, watch your hands, there is no deception here.
Let's remember, why do we need data? To perform operations on them and get the result. Often we know which subtasks need to be solved, but do not understand what kind of data is required for this.
Attention! We can manipulate operations knowing that they own the data in advance to execute them.
The object allows you to replace a dataset with a set of operations. And if it reduces the number of parts, then it simplifies part of the task!
// , /
interface BuildConfig {
id: string;
deployPath: string;
options: BuildOptions;
// ...
}
// vs
// ,
interface Project {
test(): Promise<void>;
build(): Promise<void>;
publish(): Promise<void>;
}
The transformation is very simple: f (x) -> of (), where o is less than x . The secondary hid inside the object. It would seem, what is the effect of transferring the code with the config from one place to another? But this transformation has far-reaching implications. We can do the same trick for the rest of the program.
// project.ts
// , Project .
class Project {
constructor(
private buildTester: BuildTester,
private builder: Builder,
private buildPublisher: BuildPublisher
) {}
async test(): Promise<void> {
await this.buildTester.runTests();
}
async build(): Promise<void> {
await this.builder.build();
}
async publish(): Promise<void> {
await this.buildPublisher.publish();
}
}
// builder.ts
export interface BuildOptions {
baseHref: string;
outputPath: string;
configuration?: string;
}
export class Builder {
constructor(private options: BuildOptions) {}
async build(): Promise<void> {
// ...
}
}
Now the Builder only receives the data it needs, just like other parts of the system. At the same time, the classes that receive the Builder through the constructor do not depend on the parameters that are needed to initialize it. When the details are in place, it is easier to understand the program. But there is also a weak point.
export interface ProjectParams {
id: string;
deployPath: Path | string;
configuration?: string;
buildRelevance?: BuildRelevance;
}
const distDir = new Directory(Path.fromRoot("dist"));
const buildRecordsDir = new Directory(Path.fromRoot("tmp/builds-manifest"));
export function createProject(params: ProjectParams): Project {
return new ProjectFactory(params).create();
}
class ProjectFactory {
private buildDir: Directory = distDir.getSubDir(this.params.id);
private deployDir: Directory = new Directory(
Path.from(this.params.deployPath)
);
constructor(private params: ProjectParams) {}
create(): Project {
const builder = this.createBuilder();
const buildPublisher = this.createPublisher();
return new Project(this.params.id, builder, buildPublisher);
}
private createBuilder(): NgBuilder {
return new NgBuilder({
baseHref: "/clientapp/",
outputPath: this.buildDir.path.toAbsolute(),
configuration: this.params.configuration,
});
}
private createPublisher(): BuildPublisher {
const buildHistory = this.getBuildsHistory();
return new BuildPublisher(this.buildDir, this.deployDir, buildHistory);
}
private getBuildsHistory(): BuildsHistory {
const buildRecordsFile = this.getBuildRecordsFile();
const buildRelevance = this.params.buildRelevance ?? BuildRelevance.Default;
return new BuildsHistory(buildRecordsFile, buildRelevance);
}
private getBuildRecordsFile(): BuildRecordsFile {
const buildRecordsPath = buildRecordsDir.path.join(
`${this.params.id}.json`
);
return new BuildRecordsFile(buildRecordsPath);
}
}
All the details associated with the complex structure of the original config went into the process of creating the Project object and its dependencies. You have to pay for everything. But sometimes this is a lucrative offer - to get rid of minor parts in the whole module, and concentrate them inside one factory.
Thus, OOP makes it possible to hide details, shifting them at the time of object creation. From a design standpoint, this is a superpower - the ability to get rid of unnecessary details. This makes sense if the sum of the details in the object's interface is less than in the structure it encapsulates. And if you can separate the creation of the object and its use in most of the system.
SOLID, abstraction, encapsulation ...
There are tons of books on OOP. They conduct in-depth studies reflecting the experience of writing object-oriented programs. But my view of development was turned upside down by the realization that OOP simplifies code primarily by limiting details. And I will be polar ... but unless you get rid of detail with objects, you are not using OOP.
You can try to comply with SOLID, but it doesn't make much sense if you haven't hidden minor details. It is possible to make interfaces look like objects in the real world, but that doesn't make much sense if you haven't hidden the minor details. You can improve the semantics by using nouns in your code, but ... you get the idea.
I find SOLID, patterns, and other object writing guidelines to be excellent refactoring guidelines. After completing the puzzle, you can see the whole picture and you can select the simpler parts. In general, these are important tools and metrics that require attention, but often developers move on to learning and using them before converting the program to object form.
When you know the truth
OOP is a tool for solving complex problems. Difficult tasks are won by dividing into simple ones by limiting the details. A way to reduce the number of parts is to replace the data with a set of operations.
Now that you know the truth, try to get rid of unnecessary things in your project. Match the resulting objects to SOLID. Then try to bring them to objects in the real world. Not the other way around. The main thing is in the details.
Recently wrote a VSCode extension for Extract class refactoring . I think this is a good example of object oriented code. The best I have. I would be glad to have comments on the implementation, or suggestions for improving the code / functionality. I want to issue a PR in Abracadabra in the near future