4.2 Refactoring mit KI-Werkzeugen
Nachdem wir uns kurz mit der Theorie von Refactorings beschäftigt haben, ist es nun an der Zeit, mit dem Quellcode zu arbeiten. Zunächst erfahren Sie, wie Sie bewerten können, ob Sie ein Stück Quellcode überhaupt überarbeiten sollten, nach welchen Gesichtspunkten Sie diese Entscheidung treffen und wie Ihnen KI-Tools dabei helfen können. Außerdem lernen Sie einige Strategien und Best Practices für konkrete Refactorings kennen.
Erkennen von Refactoring-Bedarf
Ihre Zeit als Entwickler ist sehr wertvoll, daher sollten Sie sie nicht mit unnötigen Arbeiten verschwenden. Deshalb ist es wichtig, zunächst herauszufinden, ob ein Refactoring zielführend ist und wo die Schwächen im Code liegen.
Für eine solche Analyse gibt es zahlreiche Werkzeuge wie beispielsweise SonarQube, die eine statische Codeanalyse durchführen. Diese Werkzeuge können Ihnen einen guten Überblick über das Gesamtsystem geben. Hier liegt auch die Schwäche vieler KI-Werkzeuge, da diese selten den gesamten Kontext Ihrer Applikation überblicken, sondern nur kleinere Teile. Optimal ist daher eine Kombination aus verschiedenen Werkzeugen: die klassische statische Codeanalyse für den Überblick und KI-Werkzeuge für die Details und das eigentliche Refactoring. Sie können die KI-Werkzeuge auch als eine Art Code-Reviewer nutzen, der Refactoring-Potenzial aufdeckt.
Code-Smells
Den Begriff »Code-Smells« können Sie fast wörtlich nehmen. Code, der Code-Smells aufweist, birgt potenzielle Probleme, die Sie mit einem Refactoring beheben können. Code-Smells sind nicht unbedingt Fehler, doch wenn Sie sich zu lange nicht um sie kümmern, können daraus ernsthafte Probleme entstehen. Typische Code-Smells sind:
-
Duplizierter Code: Der Code weist gleiche oder ähnliche Blöcke an verschiedenen Stellen auf. Beheben Sie an einer Stelle einen Fehler, müssen Sie dies auch an allen anderen Stellen tun. Die Wartbarkeit ist also reduziert, und das Potenzial für Fehler ist hoch.
-
Lange Methoden: Funktionen und Methoden sollten möglichst kompakt sein und sich nur um einen Aspekt kümmern. Je mehr Zeilen eine Funktion aufweist, desto größer ist potenziell die Komplexität.
-
Große Klassen: Ähnliches wie für Funktionen und Methoden gilt auch für Klassen. Ist eine Klasse zu groß, gemessen an der Anzahl der Zeilen, der Eigenschaften oder Methoden, ist die Wahrscheinlichkeit hoch, dass sie zu viel tut.
-
Feature Envy: Diese Art des Code-Smells bezeichnet eine Methode, die mehr auf Eigenschaften einer anderen Klasse zugreift als auf die ihrer eigenen. Ein Indiz dafür, dass die Methode an dieser Stelle falsch ist.
-
Data Clumps: Dabei handelt es sich um Gruppen von Variablen, die an verschiedenen Stellen einer Applikation gleich verwendet werden. Sie sollten in einer eigenen Klasse oder Datenstruktur zusammengefasst werden.
Code-Smells sind in verschiedenen Ausprägungen in nahezu jeder Applikation vorhanden. Mit den passenden Werkzeugen können Sie sie jedoch leicht aufdecken und beheben. Als konkretes Beispiel dient uns eine TypeScript-Klasse mit dem Namen OrderProcessor, die wie folgt aussieht:
class OrderProcessor {
orders: any[] = [];
addOrder(order: any) {
this.orders.push(order);
console.log("Order added:", order);
if (this.orders.length > 100) {
console.log("Too many orders");
}
}
processOrders() {
for (let i = 0; i < this.orders.length; i++) {
let order = this.orders[i];
console.log("Processing order:", order);
if (order.status === "NEW") {
console.log("Order is new");
} else if (order.status === "PROCESSING") {
console.log("Order is being processed");
} else if (order.status === "COMPLETED") {
console.log("Order is completed");
} else {
console.log("Unknown order status");
}
if (order.customer.address.city === "Berlin") {
console.log("Customer is from Berlin");
}
}
}
generateReport() {
let report = "Order Report\n";
report += "=============\n";
for (let i = 0; i < this.orders.length; i++) {
let order = this.orders[i];
report += `Order ${i + 1}: ${order.id}\n`;
if (order.customer.address.city === "Berlin") {
report += "Customer is from Berlin\n";
}
}
if (this.orders.length > 100) {
report += "Warning: Too many orders\n";
}
console.log(report);
}
}
Die Klasse verfügt über die Eigenschaft orders, in der sie Bestellungen speichert. Die Methode addOrder fügt neue Bestellungen hinzu, die Methode processOrder verarbeitet die Bestellungen, und die Methode generateReport erzeugt einen Bericht. Zur Analyse können Sie nahezu jedes beliebige KI-Werkzeug verwenden. Sie könnten beispielsweise den Code in die Gemini-Konsole kopieren und um eine Analyse bitten.
In diesem Beispiel nutzen wir den GitHub Copilot und die VS Code-Integration. Hier können Sie die Kombination aus Chat-Feature und Workspace verwenden, um den Code zu analysieren. Dazu öffnen Sie die Klasse in der Entwicklungsumgebung und geben folgenden Prompt im Chatfenster ein:
Prompt: @workspace /explain Does the OrderProcessor class contain code smells?
Yes, the OrderProcessor class contains several code smells. Let’s go through them step by step:
… explanation of code smells in the code …
Here is a refactored version of the OrderProcessor class addressing these issues:
interface Order {
id: number;
status: 'NEW' | 'PROCESSING' | 'COMPLETED';
customer: {
address: {
city: string;
};
};
}
class OrderProcessor {
... updated code ...
}
Changes Made:
… explanation of changes made in the code …
These changes improve the readability, maintainability, and robustness of the code.
Dieser Prompt ist sehr allgemein gehalten, sodass Sie dem Werkzeug viel Freiraum lassen, um den Code zu analysieren und Code-Smells zu finden. Wichtig bei einem solchen Prompt ist, egal welches Werkzeug Sie benutzen, dass Sie den Code möglichst umfassend zur Verfügung stellen und nicht nur einzelne Abschnitte. Je mehr Zusammenhänge Sie der KI geben, desto besser kann sie ihre Arbeit verrichten.
In dem Prompt fragen Sie explizit nach Code-Smells. Dabei handelt es sich um einen etablierten Begriff, der in der Literatur ausreichend beschrieben ist, sodass die KI damit arbeiten kann. Die Verwendung solcher Fachbegriffe, wie beispielsweise bestimmter Design-Patterns, kann die Prompts deutlich kompakter machen, da Sie die Problemstellung nicht erst umständlich umschreiben müssen. Versuchen Sie, in Ihren Prompts also möglichst Fachbegriffe zu verwenden, damit Sie das, was Sie erreichen möchten, möglichst präzise beschreiben.
Der GitHub Copilot hat einige Schwachstellen in unserem Code gefunden. Das Werkzeug identifiziert die Code-Smells, erklärt, worin das Problem besteht, und liefert gleich auch noch eine verbesserte Variante der Klasse inklusive detaillierter Erklärung der Änderungen. Folgende Code-Smells wurden gefunden und behoben:
-
Verwendung des any-Typs: Nutzen Sie den any-Typ in TypeScript, verlieren Sie die Vorteile des statischen Typsystems. Der GitHub Copilot hat für die Bestellung das Order-Interface erzeugt und verwendet dieses durchgängig in den Methoden und Eigenschaften der Klasse.
-
Einsatz magischer Zahlen: Sie sollten vermeiden, Zahlen mit einer bestimmten Bedeutung, wie beispielsweise das Limit für Bestellungen, direkt im Code zu hinterlegen. Eine bessere Lösung liefert der Copilot hier, indem er die Zahl in der Form der Eigenschaft MAX_ORDERS speichert.
-
Wiederholungen im Code: Die Prüfung, ob die Stadt in der Bestellung Berlin ist, findet sich zweimal im Code und kann ausgelagert werden.
-
Lange Methoden: Die beiden Methoden processOrders und generateReport sind verhältnismäßig lang. Der Copilot hat hier zum einen die Iteration über die Bestellungen durch die Verwendung der forEach-Methode vereinfacht und zum anderen Teile der Methoden ausgelagert, sodass sie kompakter und leichter zu lesen sind.
-
Fehlende Fehlerbehandlung: Beim Zugriff auf Eigenschaften wie city in der Überprüfung der Stadt der Bestellung geht der Code davon aus, dass alle Eigenschaften in der Kette existieren. Ist dies nicht der Fall, wirft die JavaScript-Engine eine Exception. Hier sollten Sie eine Routine zur Fehlerbehandlung einbauen.
-
Konsolenausgaben: Logging mit der console.log-Methode ist nicht immer die beste Variante. Idealerweise nutzen Sie hierfür einen eigenen Logger, dessen Verhalten Sie besser kontrollieren können.
Die Hinweise des Copilots sind sehr gut, und auch das generierte Ergebnis ist deutlich besser als die ursprüngliche Variante des Quellcodes. Einige Kritikpunkte sind im Ergebnis jedoch nicht oder nur teilweise berücksichtigt, sodass Sie noch etwas nacharbeiten müssen. So wurde der duplizierte Code nur an einer Stelle in die Methode checkCustomerCity ausgelagert. Für den Einsatz des Loggers gibt es ebenfalls keine Lösung, wobei hier auch der applikationsweite Kontext fehlt.
Zu den Code-Smells, die der Copilot nicht gefunden hat, gehört, dass die Klasse zu viele Verantwortlichkeiten abdeckt. Das Verwalten von Bestellungen und die Erzeugung eines Berichts haben nicht unbedingt etwas miteinander zu tun und könnten in separate Klassen ausgelagert werden. Der Zugriff auf die Eigenschaften der Adresse der Bestellung ist ein Beispiel für Feature Envy. Der OrderProcessor sollte nicht direkt auf das Adressobjekt zugreifen. Stattdessen sollten Sie besser eine eigene Klasse dafür einführen, die Ihnen entsprechende Methoden zur Verfügung stellt.
Sie können auch deutlich konkreter werden, wenn Sie beispielsweise eine Klasse oder Funktion haben, mit der Sie nicht zufrieden sind, aber nicht genau wissen, wie Sie sie umbauen sollen. Die nachfolgende Funktion enthält unnötige Duplikate im Code, die die KI auflösen soll:
function processOrders(orders) {
let totalRevenue = 0;
let totalDiscountedRevenue = 0;
let highValueOrders = [];
let discountedOrders = [];
for (let i = 0; i < orders.length; i++) {
const order = orders[i];
if (order.status === "completed") {
totalRevenue += order.price;
}
if (order.status === "completed" && order.discount > 0) {
totalDiscountedRevenue += order.price - order.discount;
discountedOrders.push(order);
}
if (order.status === "completed" && order.price > 100) {
if (order.discount > 0) {
discountedOrders.push(order);
}
highValueOrders.push(order);
}
}
return {
totalRevenue,
totalDiscountedRevenue,
highValueOrders,
discountedOrders,
};
}
In diesem Code-Beispiel prüfen Sie in jedem if-Block, ob der Status der Bestellung completed ist. Solcher Code entsteht schnell, wenn Sie die Anforderungen direkt in Code übersetzen und keine Optimierungen vornehmen. Das kann jedoch ChatGPT oder ein anderes Werkzeug mit dem folgenden Prompt für uns übernehmen.
Prompt: Could you please reduce the code duplicates in the following function:
(Code der Funktion …)
You can reduce the code duplication in the processOrders function by combining the conditions that check for the order’s status and applying the relevant logic inside a single block. Here’s a refactored version of your function:
function processOrders(orders) {
let totalRevenue = 0;
let totalDiscountedRevenue = 0;
let highValueOrders = [];
let discountedOrders = [];
for (const order of orders) {
if (order.status !== "completed") continue;
totalRevenue += order.price;
if (order.discount > 0) {
totalDiscountedRevenue += order.price - order.discount;
discountedOrders.push(order);
}
if (order.price > 100) {
highValueOrders.push(order);
}
}
return {
totalRevenue,
totalDiscountedRevenue,
highValueOrders,
discountedOrders,
};
}
Changes Made: explanation of the changes made …
Die KI hat den Code so umgeschrieben, dass die mehrfachen Überprüfungen des Status der Bestellung zusammengefasst wurden. Entspricht der Status nicht dem Wert completed, wird direkt mit dem nächsten Durchlauf fortgefahren. Der Code ist dadurch kompakter und einfacher zu lesen.
Wenn Sie mit Ihrem Quellcode also nicht zufrieden sind und wissen, welche Änderungen Sie sich wünschen, können Sie dies in einem Prompt formulieren, und die KI passt Ihren Code entsprechend an. Es gibt natürlich auch Code, der nicht so offensichtlich optimiert werden kann. In diesem Fall sollten Sie die Anpassungen sehr genau überprüfen. KI-Werkzeuge tendieren in solchen Fällen dazu, einfach nur Umstellungen vorzunehmen, die keine wirkliche Verbesserung des Codes darstellen.
Anti-Patterns
Sie haben bestimmt schon von Design-Patterns gehört. Das sind etablierte und wiederverwendbare Lösungen für häufig auftretende Probleme in der Softwareentwicklung. Anti-Patterns sind gewissermaßen das Gegenteil davon. Häufig sind es Muster oder Praktiken im Quellcode, die erst langfristig Probleme verursachen. Meist sind diese Lösungen aus guten Absichten entstanden, entpuppen sich jedoch bei der weiteren Entwicklung als problematisch. Typische Anti-Patterns sind beispielsweise:
-
God Object: Das God Object oder die God-Klasse ist in gewisser Weise die Weiterentwicklung einer großen Klasse. Sie ist ein zentraler Bestandteil einer Applikation. Über die Dauer der Entwicklung wurden immer mehr Aspekte in die Klasse aufgenommen, sodass sie sich um zu viele Aspekte kümmert. Der Quellcode wird dadurch deutlich schwerer zu warten.
-
Falsche Verwendung des Singleton-Patterns: Generell genießt das Singleton-Pattern einen schlechten Ruf, da es sehr leicht falsch verwendet werden kann. So kann es beispielsweise genutzt werden, um den globalen Zustand einer Applikation zu verwalten.
-
Spaghetti-Code: Weist Quellcode eine schlechte oder ungenügende Struktur auf, können Sie ihn mit einem Berg Spaghetti vergleichen: Sie ziehen an einem Ende, und der gesamte Berg bewegt sich. In solchem Quellcode wird das Beheben von Fehlern oder die Weiterentwicklung von Features sehr schwierig.
-
Golden Hammer: Dieses Anti-Pattern bezeichnet die Tatsache, dass eine etablierte Lösung für alle möglichen Probleme verwendet wird, auch wenn sie sich dafür nicht eignet.
-
Premature Optimization: Häufig trifft man in Applikationen auf das Problem, dass der Code »overengineered« ist, also dass die Lösungen unnötig kompliziert sind und schon Probleme lösen, die noch gar nicht entstanden sind.
Als Grundlage für die Analyse und anschließende Verbesserung dient uns die TypeScript-Klasse UserService:
class UserService {
private static instance: UserService;
private constructor() {}
static getInstance(): UserService {
if (!UserService.instance) {
UserService.instance = new UserService();
}
return UserService.instance;
}
processUserData(users: any[]): void {
users.forEach((user) => {
if (user.isActive) {
console.log(`Processing active user: ${user.name}`);
if (user.role === "admin") {
console.log("Admin privileges granted.");
if (
user.lastLogin >
new Date().getTime() - 1000 * 60 * 60 * 24
) {
console.log("Recent login detected.");
} else {
console.log("Login outdated.");
}
} else if (user.role === "user") {
console.log("Standard user.");
user.permissions.forEach((perm: any) => {
if (perm.isEnabled) {
console.log(`Enabled permission: ${perm.name}`);
}
});
} else {
console.log("Unknown role.");
}
} else {
console.log(`Inactive user: ${user.name}`);
}
});
}
notifyUsers(users: any[]): void {
this.processUserData(users);
}
calculateDiscounts(users: any[]): void {
const discountCache: { [key: string]: number } = {};
users.forEach((user) => {
if (discountCache[user.id]) {
console.log(
`Cached discount for user ${user.name}: ${
discountCache[user.id]
}`
);
} else {
const discount = user.purchaseHistory.length * 0.05;
discountCache[user.id] = discount;
console.log(
`Calculated discount for user ${user.name}:
${discount}`
);
}
});
}
logUserActivity(user: any): void {
console.log(`Logging activity for user: ${user.name}`);
}
generateUserReport(users: any[], reportType: string): void {
console.log(
`Generating ${reportType} report for
${users.length} users.`
);
}
}
Für das Refactoring hinsichtlich Anti-Patterns nutzen wir, wie im vorangegangenen Beispiel, wieder den GitHub Copilot in VS Code. Über die Copilot-Chat-Integration können Sie folgenden Prompt absetzen, um den Code der Klasse zu analysieren:
Prompt: @workspace /explain Does the Code of the UserService class contain anti patterns?
Yes, the UserService class contains several anti-patterns and areas for improvement. Let’s go through them step by step. Explanation of anti patterns in the code …
Refactored Example
Here’s a refactored version addressing some of these issues:
interface User {
id: string;
name: string;
isActive: boolean;
role: 'admin' | 'user' | 'unknown';
lastLogin: number;
permissions: Permission[];
purchaseHistory: any[];
}
interface Permission {
name: string;
isEnabled: boolean;
}
class UserService {
private static instance: UserService;
private constructor() {}
static getInstance(): UserService {
if (!UserService.instance) {
UserService.instance = new UserService();
}
return UserService.instance;
}
processUserData(users: User[]): void {
users.forEach((user) => {
if (user.isActive) {
this.processActiveUser(user);
} else {
console.log(`Inactive user: ${user.name}`);
}
});
}
private processActiveUser(user: User): void {
console.log(`Processing active user: ${user.name}`);
switch (user.role) {
case 'admin':
this.processAdminUser(user);
break;
case 'user':
this.processStandardUser(user);
break;
default:
console.log("Unknown role.");
}
}
private processAdminUser(user: User): void {
console.log("Admin privileges granted.");
const oneDayInMillis = 1000 * 60 * 60 * 24;
if (user.lastLogin > new Date().getTime() - oneDayInMillis) {
console.log("Recent login detected.");
} else {
console.log("Login outdated.");
}
}
private processStandardUser(user: User): void {
console.log("Standard user.");
user.permissions.forEach((perm) => {
if (perm.isEnabled) {
console.log(`Enabled permission: ${perm.name}`);
}
});
}
notifyUsers(users: User[]): void {
this.processUserData(users);
}
calculateDiscounts(users: User[]): void {
const discountCache: { [key: string]: number } = {};
users.forEach((user) => {
if (discountCache[user.id]) {
console.log(`Cached discount for user ${user.name}: ${discountCache[user.id]}`);
} else {
const discount = user.purchaseHistory.length * 0.05;
discountCache[user.id] = discount;
console.log(`Calculated discount for user ${user.name}: ${discount}`);
}
});
}
logUserActivity(user: User): void {
console.log(`Logging activity for user: ${user.name}`);
}
generateUserReport(users: User[], reportType: string): void {
console.log(`Generating ${reportType} report for ${users.length} users.`);
}
}
Summary: … By addressing these points, the code becomes more maintainable, readable, and testable.
Auch hier hat der GitHub Copilot wieder ganze Arbeit geleistet, einige Schwachstellen im Code gefunden und eine alternative Implementierung vorgeschlagen:
-
Singleton Pattern: Der Copilot hat das Singleton-Pattern im Code erkannt und weist darauf hin, dass man bei dessen Verwendung vorsichtig sein sollte.
-
Type Safety: Der ursprüngliche Code hat den any-Typ verwendet. Der Copilot hat ein User-Interface erzeugt und verwendet es durchgängig in der Klasse.
-
Single Responsibility Principle: Die Methode processUserData erledigt zu viele Aufgaben. Der Copilot hat in seiner alternativen Umsetzung die Zuständigkeiten sauber in eigene Methoden ausgelagert.
-
Reduce Nesting: Bei der Verschachtelung fällt erneut die Methode processUserData negativ auf. Durch die Aufteilung der Methode hat der Copilot auch dieses Problem gelöst. Die Kombination aus der Verletzung des Single Responsibility Principle und der schlechten Strukturierung weist auf Spaghetti-Code hin.
-
Magic Numbers: Die Berechnung, wie viele Millisekunden ein Tag hat, ist nicht auf den ersten Blick ersichtlich. In der überarbeiteten Version des Codes ist diese Berechnung in einer sprechend benannten Variablen gespeichert.
-
Logging: Der Copilot bemerkt in seiner Analyse, dass in der Klasse console.log verwendet wird und schlägt stattdessen vor, einen eigenen Logger zu benutzen.
-
Error Handling: Ein weiterer Kritikpunkt bei der Analyse ist die fehlende Fehlerbehandlung.
Wie schon bei der Analyse der Code-Smells gibt uns der Copilot bei den Anti-Patterns eine Mischung aus einer konkreten Alternativimplementierung und einigen Hinweisen, um die wir uns selbst kümmern müssen. Das Ergebnis dieses Prompts können Sie bedenkenlos in Ihrer Applikation verwenden, da es den bestehenden Code auf jeden Fall deutlich verbessert.
Der ursprüngliche Code weist noch einige weitere Schwachstellen auf, die nicht adressiert wurden:
-
Zu viele Verantwortlichkeiten: Die Klasse deckt zu viele Verantwortlichkeiten auf einmal ab. Hinweise darauf liefern die Methoden logUserActivity, generateUserReport oder calculateDiscounts.
-
Premature Optimization: Der Cache in der Methode calculateDiscounts könnte ein Hinweis auf eine unnötige Optimierung sein, die die Komplexität des Codes erhöht. An einer solchen Stelle wäre zumindest ein entsprechender Hinweis gut.
-
Unpassende Verwendung einer Methode: Die Methode notifyUsers ist lediglich ein Alias für processUserData. Solche Codestellen können auf ein Anti-Pattern hinweisen.
Bei der Arbeit mit bestehendem Code gibt es einige Best Practices, die Ihnen helfen können, Probleme zu vermeiden. Wie Sie dabei vorgehen sollten, erfahren Sie in den nächsten Abschnitten.