Komplexität

Aus Programmieren-Wiki
🚧 Diese Seite befindet sich in Bearbeitung 🚧
🤓 Diese Seite ist eine Bewertungsrichtlinie, die ab Blatt 1 annotiert und ab Blatt 2 abgezogen wird. 🤓

Beschreibung

Ein wichtiges Ziel beim Programmieren ist, dass der geschriebenen Quellcode einfach von anderen verstanden werden kann. Deshalb sollte immer der einfachste Weg gewählt werden, um eine vorgegebene Funktionalität zu implementieren. Dies bedeutet nicht, dass der Quellcode aus möglichst wenigen Anweisungen und Zeichen bestehen soll, wie zum Beispiel hier

public static int max3(int a, int b, int c) {
    return a >= b && a >= c ? a : b >= c ? b : c;
}

sondern dass auf unnötige Konstrukte, innerhalb der Vorgaben des Checkstyle und der weiteren Bewertungskriterien, verzichtet werden soll.

Möglichkeiten, Code zu vereinfachen, werden häufig erst im Laufe der Entwicklung sichtbar. Beim Überarbeiten und Weiterentwickeln sollte stets ein Augenmerk darauf gelegt werden, ob der bestehende Code unnötige Komplexität enthält und einfacher und verständlicher gestaltet werden kann.

Zudem sollte man sich mit existierenden Klassen und Methoden aus der Java Standardbibliothek vertraut machen. Zum Beispiel kann das obere Beispiel mithilfe von Math.max zu folgendem vereinfacht werden:

public static int max3(int a, int b, int c) {
    return Math.max(a, Math.max(b, c));
}


Verschachtelungstiefe

Die Logik eines Programms wird hauptsächlich durch Kontrollstrukturen gegeben. Diese lassen sich auch ineinander schreiben um dadurch sehr komplexe Logiken zu implementieren. Für das Verschachteln gibt es allerdings Regeln, sodass der Code dadurch nicht unleserlich oder gar unverständlich wird.

Generell gilt:

Kleine Methoden sind besser zu lesen als große Methoden. In kleineren Methoden fallen zudem Fehler besser auf und Sichtbarkeiten können geeigneter gewählt werden (z.B. private Hilfsmethoden). Geeignete Methodennamen ermöglichen es, selbst komplexe Logiken, leicht verständlich zu implementieren.

Negativbeispiel
public static List<Integer> primesBad(final int max) {
    final List<Integer> primes = new ArrayList<>();
 
    for(int n = 1; n <= max; n++) {
        int divisors = 0;
 
        for(int i = 1; i <= n; i++) {
            if(n % i == 0) {
                divisors++;
            }
        }
 
        if(divisors == 2) {
            primes.add(n);
        }
    }
 
    return primes;
}
  • Das Beispiel ist für die Verständlichkeit hier nicht zu komplex gewählt
  • Es muss verstanden werden, wie sich der Zustand von primes und divisors entwickelt
  • Bei kompleceren Problemen wird das schnell anstrengend und fehleranfällig
Positivbeispiel
public static List<Integer> primesGood(final int max) {
    final List<Integer> primes = new ArrayList<>();
 
    for(int n = 1; n <= max; n++) {
        if(isPrime(n)) {
            primes.add(n);
        }
    }
 
    return primes;
}
 
private static boolean isPrime(final int n) {
    int divisors = 0;
 
    for(int i = 1; i <= n; i++) {
        if(divides(i, n)) {
            divisors++;
        }
    }
 
    return divisors == 2;
}
 
private static boolean divides(final int divisor, final int dividend) {
    return dividend % divisor == 0;
}
  • Die beiden Hilfsmethoden helfen bei der Erklärung was passiert bereits durch ihre gewählten Bezeichner
  • Jede Methode hat höchstens einen veränderlichen Zustand, der nach der Ausführung vergessen werden kann
  • Die Korrektheit jeder einzelnen Funktion ist offensichtlich (für n > 0)


Bei der Bewertung unnötiger Komplexität unterscheiden wir zwei Schweregrade - klein und groß.

Kleine Fälle unnötiger Komplexität sind Code innerhalb einer Methode oder eine ganze Methode, die zu komplex gestaltet wurde. Dazu zählen unter anderem Boolesche Ausdrücke in if-Statements, die nicht vereinfacht wurden.

Große Fälle unnötiger Komplexität treten methoden- oder klassenübergreifend auf.

Beide Kategorien werden als eigene Richtlinie behandelt und geben demnach separat Abzug.


Negativbeispiel

boolean isValid() {
    if (this.sold == false && !(this.price <= 0)) {
        return true;
    }
    return false;
}

Der Ausdruck ist hier unnötig komplex und kann vereinfacht werden. this.sold == false entspricht dem Ausdruck !this.sold (! invertiert this.sold, entsprechend ist der Ausdruck nur wahr, wenn this.sold false ist). Entsprechend ist this.price > 0 eine vereinfachte Variante von !(this.price <= 0).

Positivbeispiel

boolean isValid() {
    return !this.sold && this.price > 0;
}

Hier wurde der Ausdruck entsprechend vereinfacht, sodass er leichter zu lesen und verstehen ist.


Wenn du diese Seite interessant fandest, findest du hier noch mehr Seite(n) dazu:
AssertionsDuplikateGottklasseHartcodierenIO/UIReimplementierungUngeeigneter Schleifentyp

Weitere Beispiele für unnötige Komplexität

if (a == true) {
    // ...
}

// can be simplified to

if (a) {
    // ...
}

// same for a == false
if (a) {
    return true;
} else {
    return false;
}
// this can be `return a;`

// other variations of this are:
if (!a) {
    return true;
} else {
    return false;
}

if (a) {
    return false;
}

return true;

// ...
if (a) {
    doA();
} else {
    if (b) {
        doB();
    }
}

// can be simplified to

if (a) {
    doA();
} else if (b) {
    doB();
}
if (a) {
    if (b) {
        // ...
    }
}

// can be simplified to

if (a && b) {
    // ...
}


public class Point {
    private int x = 0;
    private int y = 0;

    public Point() {
        // This constructor is redundant.
        // The same constructor (default constructor)
        // will be generated by java,
        // when none is declared.
    }
}
int a = 5;
// there is no reason to assign a 
// variable to itself.
a = a;

// ...

// more commonly this happens in methods where
// one forgets to use `this.`:
class Point {
    private int x;
    private int y;

    Point(int x, int y) {
        // The below code does assign the
        // parameter x and y the values of
        // x and y, which is redundant.
        x = x;
        y = y;
    }
}
return !(a == 1) || !(b < 5);

// could be simplified to

return a != 1 || b >= 5;
int a;
a = 5;

// instead of

int a = 5;
System.out.println(a + a + a + a + a);
// instead of
System.out.println(a * 5);
// For each primitive datatype like int or double,
// there exists a class like Integer or Double.
//
// Java automatically converts primitive datatypes to
// their classes, so you can do this:
int a = 5;
Integer b = a; // a is automatically converted to Integer

// These classes are useful for generics, because you can
// not write List<int>, you have to use the class: List<Integer>
// or when you want to return a nullable number, like this:
public static Integer getNumber() {
    if (this.scanner.hasNextInt()) {
        return this.scanner.nextInt();
    }

    return null;
}
// NOTE: In the above case, you should consider
//       returning Optional<Integer> or a dedicated
//       class instead, so you don't forget to handle
//       the null case.

// Because of the above explained automatic conversion,
// one can use the boxed class (e.g. Integer) everywhere
// an int could be used.
//
// Using the boxed class even though it is not necessary
// is considered unnecessary complexity.
// this is redundant, because java.lang is already imported
import java.lang.String;
// NOTE: There are special cases where this might be necessary,
//       in these cases please consider renaming your class.

// Imports of classes that are never used, is considered
// unnecessary as well:
import java.util.ArrayList;
a = a + 5;
// could be simplified to
a += 5;

// same for %, -, *, /, <<, >>
a %= 2; // a = a % 2;
a *= 5; // a = a * 5;
a /= 3; // a = a / 3;
a <<= 2; // a = a << 2;
a >>= 3; // a = a >> 3;