Je fouille jour et nuit dans la POO depuis plus de deux ans maintenant. Lisez une épaisse pile de livres, passez des mois à refactoriser du code procédural à orienté objet et inversement. Un ami dit que j'ai gagné la POO du cerveau. Mais ai-je confiance en la capacité de résoudre des problèmes complexes et d'écrire du code clair?
J'envie les gens qui peuvent pousser en toute confiance leur opinion délirante. Surtout quand il s'agit de développement, d'architecture. En général, ce à quoi j'aspire avec passion, mais sur quoi j'ai des doutes sans fin. Parce que je ne suis pas un génie et que je ne suis pas un MF, je n'ai pas de réussite. Mais laissez-moi mettre 5 kopecks.
Encapsulation, polymorphisme, pensée objet ...?
Aimez-vous quand vous êtes chargé de termes? J'en ai assez lu, mais les mots ci-dessus ne me disent toujours rien de particulier. J'ai l'habitude d'expliquer les choses dans une langue que je comprends. Un niveau d'abstraction, si vous voulez. Et j'ai longtemps voulu connaître la réponse à une question simple: "Que donne la POO?" De préférence avec des exemples de code. Et aujourd'hui, j'essaierai d'y répondre moi-même. Mais d'abord, un peu d'abstraction.
Complexité de la tâche
Le développeur est d'une manière ou d'une autre engagé dans la résolution de problèmes. Chaque tâche a de nombreux détails. En partant des spécificités de l'API d'interaction avec l'ordinateur, en terminant par les détails de la logique métier.
L'autre jour, j'ai ramassé une mosaïque avec ma fille. Nous avions l'habitude de collectionner des puzzles de grande taille, littéralement à partir de 9 pièces. Et maintenant, elle peut manipuler de petites mosaïques pour les enfants à partir de 3 ans. C'est intéressant! Comment le cerveau trouve sa place parmi les énigmes dispersées. Et qu'est-ce qui détermine la complexité?
À en juger par les mosaïques pour enfants, la complexité est principalement déterminée par le nombre de détails. Je ne suis pas sûr que l'analogie du puzzle couvrira l'ensemble du processus de développement. Mais que pouvez-vous comparer d'autre à la naissance d'un algorithme au moment de l'écriture d'un corps de fonction? Et il me semble que réduire la quantité de détails est l'une des simplifications les plus importantes.
Pour montrer plus clairement la caractéristique principale de la POO, parlons des tâches dont le nombre de pièces ne nous permet pas d'assembler un puzzle dans un délai raisonnable. Dans de tels cas, nous avons besoin d'une décomposition.
Décomposition
Comme vous le savez à l'école, un problème complexe peut être décomposé en problèmes plus simples afin de les résoudre séparément. L'essence de l'approche est de limiter le nombre de pièces.
Il se trouve qu'en apprenant à programmer, nous nous habituons à travailler avec une approche procédurale. Lorsqu'il y a une donnée à l'entrée que nous transformons, nous la jetons en sous-fonctions et la mappons au résultat. Finalement, nous faisons la décomposition lors du refactoring lorsque la solution est déjà là.
Quel est le problème avec la décomposition procédurale? Par habitude, nous avons besoin de données initiales, et de préférence avec une structure finalement formée. De plus, plus la tâche est grande, plus la structure de ces données initiales est complexe, plus vous devez garder à l'esprit les détails. Mais comment être sûr que les données initiales suffiront à résoudre les sous-tâches, et en même temps se débarrasser de la somme de tous les détails au plus haut niveau?
Regardons un exemple. Il n'y a pas si longtemps, j'ai écrit un script qui crée des assemblages de projets et les jette dans les dossiers nécessaires.
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);
// ...
}
// ...
}
Il peut sembler que j'ai appliqué la POO dans cette solution. Vous pouvez remplacer les implémentations de service, vous pouvez même tester quelque chose. Mais en fait, c'est un excellent exemple d'approche procédurale.
Jetez un œil à l'interface BuildConfig. C'est une structure que j'ai créée au tout début de l'écriture du code. J'ai réalisé à l'avance que je ne pouvais pas prévoir tous les paramètres à l'avance, et j'ai simplement ajouté des champs à cette structure au besoin. Au milieu du travail, la configuration était envahie par un tas de champs qui étaient utilisés dans différentes parties du système. J'ai été agacé par la présence d'un "objet" qui doit être terminé à chaque changement. Il est difficile d'y naviguer, et il est facile de casser quelque chose en confondant les noms des champs. Et pourtant, toutes les parties du système de construction dépendent de BuildConfig. Comme cette tâche n'est pas si volumineuse et critique, il n'y a pas eu de désastre. Mais il est clair que si le système était plus compliqué, j'aurais foiré le projet.
Un objet
Le principal problème de l'approche procédurale concerne les données, leur structure et leur quantité. La structure complexe des données introduit des détails qui rendent la tâche difficile à comprendre. Maintenant, surveillez vos mains, il n'y a pas de tromperie ici.
Rappelons-nous, pourquoi avons-nous besoin de données? Pour effectuer des opérations sur eux et obtenir le résultat. Souvent, nous savons quelles sous-tâches doivent être résolues, mais nous ne comprenons pas quel type de données est nécessaire pour cela.
Attention! Nous pouvons manipuler les opérations en sachant qu'ils possèdent les données à l'avance pour les exécuter.
L'objet vous permet de remplacer un ensemble de données par un ensemble d'opérations. Et si cela réduit le nombre de pièces, alors cela simplifie une partie de la tâche!
// , /
interface BuildConfig {
id: string;
deployPath: string;
options: BuildOptions;
// ...
}
// vs
// ,
interface Project {
test(): Promise<void>;
build(): Promise<void>;
publish(): Promise<void>;
}
La transformation est très simple: f (x) -> de (), où o est inférieur à x . Le secondaire s'est caché à l'intérieur de l'objet. Il semblerait, quel est l'effet du transfert du code avec la configuration d'un endroit à un autre? Mais cette transformation a des implications profondes. Nous pouvons faire la même chose pour le reste du programme.
// 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> {
// ...
}
}
Désormais, le constructeur ne reçoit que les données dont il a besoin, tout comme les autres parties du système. Dans le même temps, les classes qui reçoivent le générateur via le constructeur ne dépendent pas des paramètres nécessaires pour l'initialiser. Lorsque les détails sont en place, il est plus facile de comprendre le programme. Mais il y a aussi un point faible.
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);
}
}
Tous les détails associés à la structure complexe de la configuration d'origine sont entrés dans le processus de création de l'objet Projet et de ses dépendances. Vous devez tout payer. Mais parfois, c'est une offre lucrative - se débarrasser des petites pièces de tout le module et les concentrer dans une seule usine.
Ainsi, la POO permet de masquer des détails, en les décalant au moment de la création de l'objet. Du point de vue de la conception, il s'agit d'une superpuissance - la capacité de se débarrasser des détails inutiles. Cela a du sens si la somme des détails dans l'interface de l'objet est inférieure à celle de la structure qu'il encapsule. Et si vous pouvez séparer la création de l'objet et son utilisation dans la plupart des systèmes.
SOLIDE, abstraction, encapsulation ...
Il existe des tonnes de livres sur la POO. Ils mènent des études approfondies reflétant l'expérience de l'écriture de programmes orientés objet. Mais c'est la prise de conscience que la POO simplifie le code principalement en limitant les détails qui ont bouleversé ma vision du développement. Et je serai polaire ... mais à moins que vous ne vous débarrassiez des détails avec les objets, vous n'utilisez pas la POO.
Vous pouvez essayer de vous conformer à SOLID, mais cela n'a pas beaucoup de sens si vous n'avez pas caché des détails mineurs. Il est possible de faire en sorte que les interfaces ressemblent à des objets dans le monde réel, mais cela n'a pas beaucoup de sens si vous n'avez pas caché les détails mineurs. Vous pouvez améliorer la sémantique en utilisant des noms dans votre code, mais ... vous voyez l'idée.
Je trouve que SOLID, les modèles et d'autres directives d'écriture d'objets sont d'excellentes directives de refactoring. Après avoir terminé le puzzle, vous pouvez voir l'image entière et mettre en évidence les parties les plus simples. En général, ce sont des outils et des mesures importants qui nécessitent une attention particulière, mais souvent les développeurs passent à l'apprentissage et à leur utilisation avant de convertir le programme en forme objet.
Quand tu connais la vérité
La POO est un outil pour résoudre des problèmes complexes. Les tâches difficiles sont gagnées en les divisant en tâches simples en limitant les détails. Un moyen de réduire le nombre de pièces consiste à remplacer les données par un ensemble d'opérations.
Maintenant que vous connaissez la vérité, essayez de vous débarrasser de ce qui est inutile dans votre projet. Faites correspondre les objets résultants à SOLID. Ensuite, essayez de les amener à des objets dans le monde réel. Pas l'inverse. L'essentiel est dans les détails.
Récemment écrit une extension VSCode pour le refactoring de la classe Extract . Je pense que c'est un bon exemple de code orienté objet. Le meilleur que j'ai. Je serais heureux d'avoir des commentaires sur l'implémentation ou des suggestions pour améliorer le code / la fonctionnalité. Je souhaite publier un PR à Abracadabra dans un proche avenir