web-dev-qa-db-fra.com

Différence significative entre la gestion des collections fonctionnelles et procédurales

Je prévois une étude empirique sur le passage de fonctions - en particulier lambdas aka fonctions anonymes aka fonctions flèche. Maintenant, bien que les approches fonctionnelles ou même orientées objet soient très favorisées par rapport à la programmation procédurale/impérative de nos jours, il semble y avoir très peu de preuves empiriques de leur supériorité. Même si de nombreuses allégations différentes existent sur les raisons pour lesquelles vous êtes mieux avec programmation d'ordre supérieur ¹, il est difficile pour moi de construire des cas qui présentent une chance de différences statistiquement significatives.

Le code est plus expressif, disant quoi faire et non comment

De telles affirmations sont Nice d'un point de vue subjectif et esthétique, mais elles devraient correspondre à des différences empiriques de productivité ou de maintenabilité afin d'avoir un effet de levier.

Actuellement, je me concentre sur l'API Stream de Java car elle a apporté un changement majeur dans la façon dont vous écrivez Java. Pour l'industrie, cela s'est accompagné de réécritures importantes, d'une demande de formation des employés et de mises à jour des IDE sans beaucoup de preuves que c'est bien mieux que ce que nous avions auparavant.

Voici un exemple de ce que je veux dire où, à mon avis, l'implémentation basée sur lambda ne donnera probablement pas de meilleurs résultats - même en tenant compte du fait que les participants devraient connaître l'API Stream en plus de l'API du langage.

// 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();
}

Personnellement, je soupçonne plutôt les avantages de la collecte de flux, mais je doute que le développeur moyen Java connaisse suffisamment la surface de l'API pour ne pas se retrouver bloqué pour certaines tâches.

// 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))
    );
}

Question spécifique

Pouvez-vous construire un cas exemplaire où la gestion de collection basée sur lambda surpasse largement la gestion procédurale (boucle) en ce qui concerne le temps de compréhension, le temps d'écriture, la facilité de changement ou le temps nécessaire pour effectuer une tâche spécifique comme la correction d'un bogue ainsi que le comptage certains appels ou paramètres. Je suis également très intéressé par la façon dont vous pensez qu'il fonctionne mieux, car le moins LOC n'est pas vraiment ce que je recherche ici, mais plutôt quelque chose qui peut être mesuré dans le temps - éventuellement avec la moyenne Java développeur. La surperformance n'est pas non plus explicite en ce qui concerne les performances d'exécution.

Les exemples peuvent être un pseudo-code ou n'importe quel langage prenant en charge les deux paradigmes tels que Java, JavaScript, Scala, C #, Kotlin.


¹ en mettant l'accent sur le passage de fonctions, je suppose que OOP et FP pour être quelque peu isomorphe à cet effet (systèmes de type mis à part) car vous pouvez voir un objet comme un simple tuple de fonctions. Les objets pouvant accepter et renvoyer d'autres objets, vous avez essentiellement des fonctions d'ordre supérieur

5
nilo

moins LOC n'est pas vraiment ce que je recherche ici

Mais pourquoi? Le moins LOC est ce que vous devriez rechercher ici. Bien que les lignes de code ne constituent pas une mesure de maintenabilité vraiment fiable, vous aurez du mal à ne pas être d'accord qu'il vous faudra plus de temps pour lire 20 lignes sur 5 lignes. La programmation n'est pas seulement une question d'écriture. Il s'agit également de lecture.

Permettez-moi de vous donner une manière empirique très provisoire de juger de l’utilité des "flèches".

Voici votre code, copié presque textuellement, mais avec un changement mineur qui fait probablement toute la différence dans le monde. C'est ce que vous utiliserez pour découvrir l'utilité des méthodes "de type flèche". Ceci est votre matériel expérimental!

// 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))
    );
}

Montrez la première fonction à certaines personnes et mesurez le temps moyen qu'il leur faut pour comprendre le sens voul. Montrez ensuite la deuxième fonction à certaines personnes autre (évidemment), mesurant à nouveau le temps moyen de compréhension totale. Demandez-leur de simplement produire un nom approprié pour la fonction. Comparez les résultats. Calculez le taux d'amélioration de la compréhension. Vous pouvez peut-être y trouver des différences statistiquement significatives!

Le code est plus expressif, disant quoi faire et non comment

Oui, c'est sûr!

De telles affirmations sont Nice d'un point de vue subjectif et esthétique, mais elles devraient correspondre à des différences empiriques de productivité ou de maintenabilité afin d'avoir un effet de levier.

Eh bien, appliquez le rapport obtenu ci-dessus (qui est juste empirique, basé sur les fonctions spécifiques que vous avez montrées aux codeurs) dans le cas d'avoir une classe avec 5 méthodes ou 10 méthodes. Les différences auront tendance à devenir importantes assez rapidement!


Du monde extérieur, j'ai lu le nom des fonctions que vous avez publiées et je "comprends". Mais cela ne devrait pas vous distraire ni quiconque du fait que quelqu'un, quelque part, devra "comprendre le point" non pas du nom, mais du conten. Quelqu'un devra également produire le contenu. Quelqu'un devra revoir le contenu. Il n'y a rien d'esthétique ou de subjectif à propos de moins d'effort dactylographie, moins d'effort réflexion, moins d'effort révision, moins de place pour bugs =, moins effort de formation pour les nouveaux arrivants, etc. Ces choses se traduisent directement en productivité et, par conséquent, en corrélation inverse avec les ressources dépensées!

Je comprends que ce n'est peut-être pas la réponse que vous cherchez, mais, à mon humble avis, il y a une valeur objective, non triviale et mesurable dans en le gardant court, simple et expressif.

11
Vector Zita

Vous avez déjà accepté et répondu et c'est un peu une note secondaire, mais je n'ai jamais été vraiment satisfait de l'API streams en Java. La conception est très orientée OO et (ironiquement) quelque peu procédurale, ce qui peut conduire à un code IMO très laid (et illisible). C'est dommage car les fonctionnalités autour des références de fonction qui ont été introduites en même temps peuvent éviter beaucoup de ce bruit. Pour cette même raison, il est assez facile de créer des fonctions réutilisables qui nettoient les choses. Voici à quoi ressemble le premier exemple une fois que vous avez introduit ceci:

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

Cela vous donne quelque chose qui ressemble un peu plus à la façon dont les choses sont faites dans un vrai langage fonctionnel. C'est plus concis et, je pense, compréhensible. Il existe différentes manières de les définir en fonction de vos besoins. Je peux fournir quelques exemples d'implémentations si nécessaire.

3
JimmyJames

De manière générale, il serait incorrect de dire que le code "fonctionnel" (ou le code utilisant Java streams) est toujours meilleur que le code "impératif") (ou le code utilisant Java). Dans certains cas, c'est beaucoup mieux, mais dans certains cas, cela peut rendre le code moins lisible. Pour une analyse détaillée, voir l'article 45 (Utiliser judicieusement les flux ) dans la troisième édition du livre "Effective Java".

Cela étant dit, il est facile de trouver des cas où votre premier exemple de flux pourrait être facilement amélioré, contrairement à votre premier exemple de boucle. Imaginez que vous ayez également besoin d'une fonction qui calcule le salaire total des employés aux cheveux bleus, et des fonctions similaires pour les employés aux cheveux verts, aux cheveux orange, etc. Dans la version impérative, vous vous retrouvez avec un cauchemar de copier-coller, mais dans la version fonctionnelle, vous pouvez simplement passer le prédicat de filtrage comme argument de fonction supplémentaire.

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

Bien sûr, vous pouvez passer le prédicat à la méthode procédurale/impérative (comme un commentaire populaire l'a souligné), mais il deviendrait alors fonctionnel (une fonction d'ordre supérieur). L'essence de la programmation fonctionnelle est de trouver des façons déclaratives et lisibles de combiner de petites fonctions, évidemment correctes, en une seule fonctionnalité complexe, et l'API Stream n'est qu'une façon pour y parvenir.

Un autre exemple serait la gestion d'événements futurs. Imaginez que vous souhaitez traiter tous les employés qui seront employés à l'avenir: une belle liste d'événements inconnus et potentiellement infinie. Ils ne peuvent pas être itérés dans une boucle, mais ils peuvent toujours être traités de manière déclarative à l'aide d'une bibliothèque de flux réactifs. Le même serait très illisible en utilisant un code impératif.

2
lbalazscs