it-swarm.com.de

Signifikanter Unterschied zwischen funktionaler und prozeduraler Sammlungsbehandlung

Ich plane eine empirische Studie zur Funktionsübergabe - insbesondere Lambdas alias anonyme Funktionen alias Pfeilfunktionen. Obwohl funktionale oder sogar objektorientierte Ansätze heutzutage gegenüber prozeduraler/imperativer Programmierung stark bevorzugt werden, scheint es nur sehr wenige empirische Beweise für ihre Überlegenheit zu geben. Obwohl es viele verschiedene Behauptungen gibt, warum Sie mit Programmierung höherer Ordnung ¹ besser dran sind, fällt es mir schwer, Fälle zu konstruieren, für die eine Chance besteht statistisch signifikante Unterschiede.

Der Code ist ausdrucksvoller und sagt , was zu tun ist und nicht wie

Behauptungen wie diese sind aus subjektiver und ästhetischer Sicht nett, aber sie müssten empirischen Unterschieden in der Produktivität oder Wartbarkeit zugeordnet werden, um eine Hebelwirkung zu erzielen.

Derzeit konzentriere ich mich auf die Stream-API von Java, da sie die Art und Weise, wie Sie Java schreiben, erheblich verändert hat. Für die Branche ging dies mit großen Umschreibungen, der Nachfrage nach Mitarbeiterschulungen und Aktualisierungen von IDEs einher, ohne dass viele Beweise dafür vorliegen, dass dies weitaus besser ist als das, was wir zuvor hatten.

Hier ist ein Beispiel dafür, was ich meine, wenn meiner Meinung nach die Lambda-basierte Implementierung wahrscheinlich keine besseren Ergebnisse liefert - selbst wenn berücksichtigt wird, dass die Teilnehmer die Stream-API zusätzlich zur Sprach-API kennen müssten.

// loop-based
public int inactiveSalaryTotal(List<Employee> employees) {
    int total = 0;
    for (Employee employee : employees) {
        if (!employee.isActive()) {
            total += employee.getSalary();
        }
    }
    return total;
}

// lambda-based
public int inactiveSalaryTotal(List<Employee> employees) {
    return employees.stream()
                   .filter(e -> !e.isActive())
                   .mapToInt(Employee::getSalary)
                   .sum();
}

Ich persönlich vermute eher Vorteile in Bezug auf das Sammeln von Streams, aber ich bezweifle, dass der durchschnittliche Java Entwickler die API-Oberfläche gut genug kennt, um für bestimmte Aufgaben nicht gestrandet zu sein.

// loop-based
public Map<String, Double> averageSalaryByPosition(List<Employee> employees) {
    Map<String, List<Employee>> groups = new HashMap<>();
    for (Employee employee : employees) {
        String position = employee.getPosition();
        if (groups.containsKey(position)) {
            groups.get(position).add(employee);
        } else {
            List<Employee> group = new ArrayList<>();
            group.add(employee);
            groups.put(position, group);
        }
    }
    Map<String, Double> averages = new HashMap<>();
    for (Map.Entry<String, List<Employee>> group : groups.entrySet()) {
        double sum = 0;
        List<Employee> groupEmployees = group.getValue();
        for (Employee employee : groupEmployees) {
            sum += employee.getSalary();
        }
        averages.put(group.getKey(), sum / groupEmployees.size());
    }
    return averages;
}

// lambda-based
public Map<String, Double> averageSalaryByPosition(List<Employee> employees) {
    return employees.stream().collect(
            groupingBy(Employee::getPosition, averagingInt(Employee::getSalary))
    );
}

Spezifische Frage

Können Sie einen beispielhaften Fall konstruieren, in dem die lambda-basierte Sammlungsbehandlung die prozedurale Behandlung (Schleife) um ein Vielfaches übertrifft, entweder in Bezug auf die Verständniszeit, die Schreibzeit, die Leichtigkeit der Änderung oder die Zeit, die für die Ausführung einer bestimmten Aufgabe wie das Beheben eines Fehlers oder das Zählen erforderlich ist? bestimmte Aufrufe oder Parameter. Ich bin auch sehr daran interessiert, wie Sie denken, dass es besser abschneidet, denn am wenigsten LOC ist nicht wirklich das, wonach ich hier suche, sondern etwas, das rechtzeitig gemessen werden kann - schließlich mit dem durchschnittlichen Java Entwickler. Eine Outperformance ist auch in Bezug auf die Laufzeitleistung ausdrücklich nicht gemeint.

Beispiele können Pseudocode oder eine beliebige Sprache sein, die beide Paradigmen wie Java, JavaScript, Scala, C # und Kotlin unterstützt.


¹ Ich gehe davon aus, dass OOP und FP für diesen Zweck etwas isomorph sind (abgesehen von Typsystemen), da Sie eine anzeigen können Objekt als nur ein Tupel von Funktionen. Wenn Objekte andere Objekte akzeptieren und zurückgeben können, haben Sie grundsätzlich Funktionen höherer Ordnung

5
nilo

zumindest ist LOC hier nicht wirklich das, wonach ich suche

Aber warum? Am wenigsten LOC ist das, wonach Sie sollten hier suchen. Während Codezeilen keine wirklich zuverlässige Wartbarkeitsmaßnahme darstellen, werden Sie kaum widersprechen können, dass Sie mehr Zeit benötigen, um 20 Zeilen über 5 Zeilen zu lesen. Beim Programmieren geht es nicht nur ums Schreiben. Es geht auch um Lesen.

Lassen Sie mich Ihnen einen empirischen Entwurf zur Beurteilung der Nützlichkeit von "Pfeilen" geben.

Hier ist Ihr Code, kopiert fast wörtlich, aber mit einer geringfügigen Änderung, die wahrscheinlich den Unterschied in der Welt ausmacht. Dies ist, was Sie verwenden werden, um herauszufinden, wie nützlich "Pfeil" -Methoden am Ende sind. Dies ist dein experimentelles Material!

// loop-based
public Map<String, Double> whatDoesThisFunctionDo(List<Employee> employees) {
    Map<String, List<Employee>> groups = new HashMap<>();
    for (Employee employee : employees) {
        String position = employee.getPosition();
        if (groups.containsKey(position)) {
            groups.get(position).add(employee);
        } else {
            List<Employee> group = new ArrayList<>();
            group.add(employee);
            groups.put(position, group);
        }
    }
    Map<String, Double> averages = new HashMap<>();
    for (Map.Entry<String, List<Employee>> group : groups.entrySet()) {
        double sum = 0;
        List<Employee> groupEmployees = group.getValue();
        for (Employee employee : groupEmployees) {
            sum += employee.getSalary();
        }
        averages.put(group.getKey(), sum / groupEmployees.size());
    }
    return averages;
}

// lambda-based
public Map<String, Double> whatDoesThisFunctionDo(List<Employee> employees) {
    return employees.stream().collect(
            groupingBy(Employee::getPosition, averagingInt(Employee::getSalary))
    );
}

Zeigen Sie einigen Personen die erste Funktion und messen Sie die durchschnittliche Zeit, die sie benötigen, um das beabsichtigte Bedeutung zu verstehen. Zeigen Sie dann einigen anderen (offensichtlich) Personen die zweite Funktion und messen Sie erneut die durchschnittliche Zeit bis zum vollständigen Verständnis. Bitten Sie sie, einfach einen geeigneten Namen für die Funktion anzugeben. Vergleichen Sie die Ergebnisse. Berechnen Sie das Verhältnis zur Verbesserung des Verständnisses. Vielleicht können Sie dort statistisch signifikante Unterschiede feststellen!

Der Code ist ausdrucksvoller und sagt, was zu tun ist und nicht wie

Ja, das ist es definitiv!

Behauptungen wie diese sind aus subjektiver und ästhetischer Sicht nett, aber sie müssten empirischen Unterschieden in der Produktivität oder Wartbarkeit zugeordnet werden, um eine Hebelwirkung zu erzielen.

Wenden Sie das oben erhaltene Verhältnis (das nur empirisch ist, basierend auf den spezifischen Funktionen, die Sie den Codierern gezeigt haben) an, wenn Sie eine Klasse mit 5 Methoden oder 10 Methoden haben. Unterschiede werden ziemlich schnell signifikant!


Von außen lese ich den Namen der Funktionen, die Sie gepostet haben, und "verstehe". Dies sollte Sie oder andere jedoch nicht davon ablenken, dass jemand, irgendwo, muss den Punkt nicht anhand des Namens, sondern anhand des Inhalts "verstehen". Jemand muss auch produzieren den Inhalt. Jemand muss überprüfen den Inhalt. Es gibt nichts Ästhetisches oder Subjektives an weniger Aufwand Tippen, weniger Aufwand Denken, weniger Aufwand Überprüfen, weniger Raum für Fehler =, weniger Schulungsaufwand für Neulinge usw. Diese Dinge führen direkt zur Produktivität und korrelieren daher umgekehrt mit den verbrauchten Ressourcen!

Ich verstehe, dass dies möglicherweise nicht die Antwort ist, nach der Sie suchen, aber meiner Meinung nach gibt es einen objektiven, nicht trivialen und messbaren Wert in kurz, einfach und ausdrucksstark.

11
Vector Zita

Sie haben bereits akzeptiert und geantwortet, und dies ist eine kleine Randnotiz, aber ich war noch nie wirklich zufrieden mit der Streams-API in Java. Das Design ist sehr OO-zentriert und (ironischerweise) etwas prozedural, was zu sehr hässlichem (und unlesbarem) Code IMO führen kann. Es ist bedauerlich, dass die gleichzeitig eingeführten Funktionen rund um Funktionsreferenzen viel von diesem Rauschen vermeiden können. Aus dem gleichen Grund ist es ziemlich einfach, wiederverwendbare Funktionen zu erstellen, die die Dinge bereinigen. So sieht das erste Beispiel aus, wenn Sie dies einführen:

public int inactiveSalaryTotal(List<Employee> employees) {
  return sum(map(filter(employees, Employee::isActive), Employee::getSalary);
}

Dies gibt Ihnen etwas, das eher der Vorgehensweise in einer echten funktionalen Sprache ähnelt. Es ist prägnanter und, denke ich, verständlich. Es gibt verschiedene Möglichkeiten, wie diese je nach Ihren Anforderungen definiert werden können. Bei Bedarf kann ich einige Beispielimplementierungen bereitstellen.

3
JimmyJames

Generell wäre es falsch zu sagen, dass der "funktionale" Code (oder der Code, der Java Streams) verwendet) immer besser ist als der "imperative" Code (oder der Code mit Java Schleifen). In einigen Fällen ist es viel besser, aber in einigen Fällen kann der Code weniger lesbar sein. Eine detaillierte Analyse finden Sie unter Punkt 45 (Streams mit Bedacht verwenden ) in der dritten Ausgabe des Buches "Effective Java".

Abgesehen davon ist es leicht, Fälle zu finden, in denen Ihr erstes Stream-Beispiel leicht verbessert werden könnte, während Ihr erstes Loop-Beispiel dies nicht könnte. Stellen Sie sich vor, Sie benötigen auch eine Funktion, die das Gesamtgehalt der blauhaarigen Mitarbeiter berechnet, und ähnliche Funktionen für grünhaarige, orangehaarige usw. Mitarbeiter. In der imperativen Version würde es zu einem Alptraum beim Kopieren und Einfügen kommen, in der funktionalen Version könnten Sie jedoch einfach das Filterprädikat als zusätzliches Funktionsargument übergeben.

public int salaryTotal(List<Employee> employees, Predicate<Employee> condition) {
    return employees.stream()
                    .filter(condition)
                    .mapToInt(Employee::getSalary)
                    .sum();
}

Natürlich könnten Sie das Prädikat an die prozedurale/imperative Methode übergeben (wie ein populärer Kommentar hervorhob), aber dann würde es funktionsfähig werden (eine Funktion höherer Ordnung). Die Essenz der funktionalen Programmierung besteht darin, deklarative, lesbare Wege zu finden, um kleine, offensichtlich korrekte Funktionen zu einer komplexen Funktionalität zu kombinieren, und die Stream-API ist nur ein Weg um das zu erreichen.

Ein weiteres Beispiel wäre die Behandlung zukünftiger Ereignisse. Stellen Sie sich vor, Sie möchten alle Mitarbeiter verarbeiten, die in Zukunft beschäftigt sein werden: eine nette unbekannte und möglicherweise unendliche Liste von Ereignissen. Sie können nicht in einer Schleife iteriert werden, können jedoch mithilfe einer reaktiven Stream-Bibliothek deklarativ verarbeitet werden. Dasselbe wäre mit imperativem Code sehr unlesbar.

2
lbalazscs