Der Programmcode wird von oben nach unten geschrieben. Anweisungen werden in derselben Reihenfolge gelesen und ausgefĂŒhrt. Das ist logisch und jeder hat sich lange daran gewöhnt. In einigen FĂ€llen können Sie die Reihenfolge der VorgĂ€nge Ă€ndern. Aber manchmal ist die Reihenfolge der Funktionsaufrufe wichtig, obwohl dies syntaktisch nicht offensichtlich ist. Es stellt sich heraus, dass der Code auch nach der Neuanordnung der Anrufe noch funktioniert und das Ergebnis unerwartet ist.
Einmal fiel mir ein Àhnlicher Code auf.
Das problem
Als ich einmal in einem gemeinsamen Projekt im Code einer anderen Person herumstocherte, entdeckte ich eine Funktion wie:
public Product fill(Product product, Images images, Prices prices, Availabilities availabilities){ priceFiller.fill(product, prices);
NatĂŒrlich fĂ€llt nicht der âelegantesteâ Stil auf: Klassen speichern Daten (POJO), Funktionen Ă€ndern eingehende Objekte ...
Im Allgemeinen scheint es nichts zu sein. Wir haben ein Objekt, in dem nicht genĂŒgend Daten vorhanden sind, und es gibt Daten selbst, die aus anderen Quellen (Diensten) stammen, die wir jetzt in dieses Objekt einfĂŒgen, um es zu vervollstĂ€ndigen.
Aber es gibt einige Nuancen.
- Mir gefĂ€llt die Tatsache nicht, dass keine einzige Funktion im Stil von FP geschrieben ist und das als Argument ĂŒbergebene Objekt modifiziert.
- Angenommen, dies wurde durchgefĂŒhrt, um die Verarbeitungszeit und die Anzahl der erstellten Objekte zu verringern.
- Nur ein Kommentar im Code besagt, dass die Reihenfolge der Aufrufe wichtig ist, und Sie sollten beim Einbetten des neuen
Filler
vorsichtig sein
- Aber die Anzahl der Personen, die an dem Projekt arbeiten, betrÀgt mehr als 1 und nicht jeder kennt diesen Trick. Besonders neue Leute im Team (nicht unbedingt im Unternehmen).
Der letzte Punkt hat mich besonders beschĂŒtzt. Immerhin ist die API so aufgebaut, dass wir nach dem Aufruf dieser Funktion nicht wissen, was sich im Objekt geĂ€ndert hat. Zum Beispiel haben wir vor und nach Product::getImages
Methode, die vor dem Aufrufen der Product::getImages
eine leere Liste und dann eine Liste mit Bildern zu unserem Produkt erstellt.
Mit Filler
sieht es noch schlimmer aus. AvailabilityFiller
macht nicht deutlich, dass es erwartet, dass Informationen ĂŒber den Preis der Ware bereits in das ĂŒbertragene Objekt eingebettet sind.
Und so habe ich mir ĂŒberlegt, wie ich meine Kollegen vor der fehlerhaften Nutzung von Funktionen schĂŒtzen kann.
Vorgeschlagene Lösungen
Zuerst habe ich beschlossen, diesen Fall mit meinen Kollegen zu besprechen. Leider schienen mir alle von ihnen vorgeschlagenen Lösungen nicht der richtige Ansatz zu sein.
Laufzeitausnahme
Eine der vorgeschlagenen Optionen war: und Sie schreiben in den AvailabilityFiller
am Anfang der Funktion Objects.requireNonNull(product.getPrices)
und dann wird jeder Programmierer bereits wÀhrend lokaler Tests einen Fehler erhalten.
- Aber der Preis ist möglicherweise nicht vorhanden. Wenn der Service nicht verfĂŒgbar war oder ein anderer Fehler aufgetreten ist, sollte das Produkt den Status "Nicht vorrĂ€tig" erhalten. Wir mĂŒssen alle Arten von Flags oder irgendetwas zuordnen, um "keine Daten" von "nicht einmal angefordert" zu unterscheiden.
- Wenn Sie eine Ausnahme in
getPrices
selbst getPrices
, verursachen wir dieselben Probleme wie modernes Java mit Listen
- Angenommen, eine Liste wird an eine Funktion ĂŒbergeben, die die get-Methode in ihrer API anbietet. Ich weiĂ, dass Sie die ĂŒbertragenen Objekte nicht Ă€ndern, sondern neue erstellen mĂŒssen. Im Endeffekt bietet uns die API eine solche Methode, aber zur Laufzeit kann ein Fehler auftreten, wenn es sich um eine unverĂ€nderliche Liste handelt, wie sie beispielsweise von Collectors.toList () bezogen wird.
- Wenn
AvailabilityFiller
von einer anderen Person verwendet wird, kann der Programmierer, der den Aufruf geschrieben hat, das Problem nicht sofort erkennen. Erst nach dem Start und Debuggen. Dann muss er noch den Code verstehen, um herauszufinden, woher die Daten stammen.
Test
"Und Sie schreiben einen Test, der abbricht, wenn Sie die Reihenfolge der Anrufe Àndern." d.h. Wenn alle Filler
ein âneuesâ Produkt zurĂŒckgeben, stellt sich Folgendes heraus:
given(priceFillerMock.fill(eq(productMock), any())).willReturn(productWithPricesMock); given(availabilityFillerMock.fill(eq(productMockWithPrices), any())).willReturn(productMockWithAvailabilities); given(imageFillerMock.fill(eq(productMockWithAvailabilities), any())).willReturn(productMockWithImages); var result = productFiller.fill(productMock, p1, p2, p3); assertThat("unexpected return value", result, is(productMockWithImages));
- Ich mag keine Tests, die so "White-Box" sind
- Bricht mit jedem neuen
Filler
- Es bricht ab, wenn die Reihenfolge der unabhÀngigen Anrufe geÀndert wird
- Auch hier wird das Problem der Wiederverwendung von
AvailabilityFiller
selbst nicht gelöst
Eigene Versuche, das Problem zu lösen
Idee
Ich denke, Sie haben bereits vermutet, dass ich das Problem auf der Kompilierungsebene lösen möchte. Nun, warum brauche ich eine kompilierte Sprache mit starker Typisierung, wenn ich den Fehler nicht verhindern kann?
Und ich fragte mich, ob ein Objekt ohne zusÀtzliche Daten und ein "erweitertes" Objekt zur selben Klasse gehören?
WÀre es nicht richtig, die verschiedenen möglichen ZustÀnde eines Objekts als separate Klassen oder Schnittstellen zu beschreiben?
Meine Idee war also:
public Product fill(<? extends BaseProduct> product, Images images, Prices prices, Availabilities availabilities){ var p1 = priceFiller.fill(product, prices); var p2 = availabilityFiller.fill(p1, availabilities); return imageFiller.fill(p2, images); } PriceFiller public ProductWithPrices fill(<? extends BaseProduct> product, Prices prices) AvailabilityFiller public ProductWithAvailabilities fill(<? extends ProductWithPrices> product, Prices prices) public <BaseProduct & PriceAware & AvailabilityAware> fill(<? extends BaseProduct & PriceAware> product, Prices prices)
Das heiĂt Das ursprĂŒnglich definierte Produkt ist eine Instanz einer anderen als der zurĂŒckgegebenen Klasse, die bereits DatenĂ€nderungen anzeigt.
Filler
in ihren APIs genau an, welche Daten sie benötigen und was sie zurĂŒckgeben.
Auf diese Weise können Sie die falsche Anrufreihenfolge verhindern.
Implementierung
Wie kann man dies in Java in die RealitÀt umsetzen? (Denken Sie daran, dass das Erben von mehreren Klassen in Java nicht möglich ist.)
KomplexitĂ€t wird durch unabhĂ€ngige Operationen hinzugefĂŒgt. Beispielsweise können Bilder vor und nach dem HinzufĂŒgen von Preisen sowie ganz am Ende der Funktion hinzugefĂŒgt werden.
Dann vielleicht
class ProductWithImages extends BaseProduct implements ImageAware{} class ProductWithImagesAndPrices extends BaseProduct implements ImageAware, PriceAware{} class Product extends BaseProduct implements ImageAware, PriceAware, AvailabilityAware{}
Wie soll man das alles beschreiben?
Adapter erstellen?
public ProductWithImagesAndPrices(<? extends BaseProduct & PriceAware> base){ this.base = base; this.images = Collections.emptyList(); } public long getId(){ return this.base.getId(); } public Price getPrice(){ return this.base.getPrice(); } public List<Image> getImages(){ return this.images; }
Daten / Links kopieren?
public ProductWithImagesAndPrices(<? extends BaseProduct & PriceAware> base){ this.id = base.getId(); this.prices = base.getPrices(); this.images = Collections.emptyList(); } public List<Image> getImages(){ return this.images; }
Wie bereits bemerkt, lÀuft alles auf eine riesige Menge Code hinaus. Und das trotz der Tatsache, dass ich im Beispiel nur 3 Arten von Eingabedaten hinterlassen habe. In der realen Welt kann es noch viel mehr geben.
Es stellt sich heraus, dass sich die Kosten fĂŒr das Schreiben und Verwalten eines solchen Codes nicht rechtfertigen, obwohl mir die Idee, den Staat in getrennte Klassen aufzuteilen, sehr attraktiv erschien.
RĂŒckzug
Wenn Sie sich andere Sprachen ansehen, ist dieses Problem irgendwo leichter zu lösen, aber irgendwo nicht.
In Go können Sie beispielsweise einen Verweis auf eine erweiterbare Klasse schreiben, ohne die Methoden "kopieren" oder "ĂŒberladen" zu mĂŒssen. Aber es geht nicht um Go
Ein weiterer Exkurs
WÀhrend des Schreibens dieses Artikels kam eine andere mögliche Lösung mit Proxy
, bei der nur neue Methoden geschrieben werden mussten, die jedoch eine Hierarchie von Schnittstellen erforderten. Im Allgemeinen unheimlich, wĂŒtend und nicht geeignet. Wenn jemand plötzlich interessiert ist:
Vor dem Zubettgehen und Essen ist es nicht empfehlenswert, sich das anzuschauen public class Application { public static void main(String[] args) { var baseProduct = new BaseProductProxy().create(new BaseProductImpl(100L)); var productWithPrices = fillPrices(baseProduct, BigDecimal.TEN); var productWithAvailabilities = fillAvailabilities(productWithPrices, "available"); var productWithImages = fillImages(productWithAvailabilities, List.of("url1, url2")); var product = productWithImages; System.out.println(product.getId()); System.out.println(product.getPrice()); System.out.println(product.getAvailability()); System.out.println(product.getImages()); } static <T extends BaseProduct> ImageAware fillImages(T base, List<String> images) { return (ImageAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{ImageAware.class, BaseProduct.class}, new MyInvocationHandler<>(base, new ImageAware() { @Override public List<String> getImages() { return images; } })); } static <T extends BaseProduct> PriceAware fillPrices(T base, BigDecimal price) { return (PriceAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{PriceAware.class}, new MyInvocationHandler<>(base, new PriceAware() { @Override public BigDecimal getPrice() { return price; } })); } static AvailabilityAware fillAvailabilities(PriceAware base, String availability) { return (AvailabilityAware) Proxy.newProxyInstance(base.getClass().getClassLoader(), new Class[]{AvailabilityAware.class}, new MyInvocationHandler<>(base, new AvailabilityAware() { @Override public String getAvailability() { return base.getPrice().intValue() > 0 ? availability : "sold out"; } })); } static class BaseProductImpl implements BaseProduct { private final long id; BaseProductImpl(long id) { this.id = id; } @Override public long getId() { return id; } } static class BaseProductProxy { BaseProduct create(BaseProduct base) { return (BaseProduct) Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[]{BaseProduct.class}, new MyInvocationHandler<>(base, base)); } } public interface BaseProduct { default long getId() { return -1L; } } public interface PriceAware extends BaseProduct { default BigDecimal getPrice() { return BigDecimal.ZERO; } } public interface AvailabilityAware extends PriceAware { default String getAvailability() { return "sold out"; } } public interface ImageAware extends AvailabilityAware { default List<String> getImages() { return Collections.emptyList(); } } static class MyInvocationHandler<T extends BaseProduct, U extends BaseProduct> implements InvocationHandler { private final U additional; private final T base; MyInvocationHandler(T base, U additional) { this.additional = additional; this.base = base; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Arrays.stream(additional.getClass().getInterfaces()).anyMatch(i -> i == method.getDeclaringClass())) { return method.invoke(additional, args); } var baseMethod = Arrays.stream(base.getClass().getMethods()).filter(m -> m.getName().equals(method.getName())).findFirst(); if (baseMethod.isPresent()) { return baseMethod.get().invoke(base, args); } throw new NoSuchMethodException(method.getName()); } } }
Fazit
Was fÀllt aus? Einerseits gibt es einen interessanten Ansatz, der unterschiedliche Klassen auf ein "Objekt" in verschiedenen ZustÀnden anwendet und die Verhinderung von Fehlern garantiert, die durch eine falsche Abfolge von Aufrufen von Methoden verursacht werden, die dieses Objekt modifizieren.
Auf der anderen Seite lÀsst dieser Ansatz Sie so viel Code schreiben, dass Sie ihn sofort ablehnen möchten. Ein Haufen von Interfaces und Klassen erschwert nur das VerstÀndnis des Projekts.
In meinem anderen Projekt habe ich immer noch versucht, diesen Ansatz zu verwenden. Und zuerst auf der Interface-Ebene war alles in Ordnung. Ich habe die Funktionen geschrieben:
<T extends Foo> List<T> firstStep(List<T> ts){} <T extends Foo & Bar> List<T> nStep(List<T> ts){} <T extends Foo> List<T> finalStep(List<T> ts){}
Nachdem somit angezeigt wurde, dass ein bestimmter Datenverarbeitungsschritt zusÀtzliche Informationen erfordert, die weder zu Beginn der Verarbeitung noch zu deren Ende benötigt werden.
Mit mock
'und habe ich es geschafft, den Code zu testen. Aber als es um die Implementierung ging und die Datenmenge und die verschiedenen Quellen zu wachsen begannen, gab ich schnell auf und verĂ€nderte alles zu einem ânormalenâ Aussehen. Alles funktioniert und niemand beschwert sich. Es stellt sich heraus, dass die Effizienz und Einfachheit des Codes ĂŒber die "Verhinderung" von Fehlern siegt und Sie die richtige Abfolge von Aufrufen manuell nachverfolgen können, auch wenn der Fehler nur in der manuellen Testphase auftritt.
Vielleicht hĂ€tte ich ganz andere Lösungen, wenn ich einen Schritt zurĂŒckgetreten wĂ€re und mir den Code von der anderen Seite angesehen hĂ€tte. Aber so kam es, dass ich mich fĂŒr diese bestimmte kommentierte Zeile interessierte.
Bereits am Ende des Artikels können Sie sich vorstellen, wie Produktdaten in Form eines Builder
, der nach dem HinzufĂŒgen der definierten Daten eine andere Schnittstelle zurĂŒckgibt, da es nicht schön ist, Setter in Interfaces zu beschreiben. Auch hier hĂ€ngt alles von der KomplexitĂ€t der Logik zum Erstellen von Objekten ab. Wenn Sie mit Spring Security gearbeitet haben, sind Sie mit dieser Art von Lösung vertraut.
FĂŒr mein Beispiel gilt:
Builder-Pattern-basierte Lösung public class Application_2 { public static void main(String[] args) { var product = new Product.Builder() .id(1000) .price(20) .availability("available") .images(List.of("url1, url2")) .build(); System.out.println(product.getId()); System.out.println(product.getAvailability()); System.out.println(product.getPrice()); System.out.println(product.getImages()); } static class Product { private final int price; private final long id; private final String availability; private final List<String> images; private Product(int price, long id, String availability, List<String> images) { this.price = price; this.id = id; this.availability = availability; this.images = images; } public int getPrice() { return price; } public long getId() { return id; } public String getAvailability() { return availability; } public List<String> getImages() { return images; } public static class Builder implements ProductBuilder, ProductWithPriceBuilder { private int price; private long id; private String availability; private List<String> images; @Override public ProductBuilder id(long id) { this.id = id; return this; } @Override public ProductWithPriceBuilder price(int price) { this.price = price; return this; } @Override public ProductBuilder availability(String availability) { this.availability = availability; return this; } @Override public ProductBuilder images(List<String> images) { this.images = images; return this; } public Product build(){ var av = price > 0 && availability != null ? availability : "sold out"; return new Product(price, id, av, images); } } public interface ProductBuilder { ProductBuilder id(long id); ProductBuilder images(List<String> images); ProductWithPriceBuilder price(int price); Product build(); } public interface ProductWithPriceBuilder{ ProductBuilder availability(String availability); } } }
Also das:
- Schreiben Sie saubere Funktionen
- Schreiben Sie schönen und klaren Code
- Denken Sie daran, dass KĂŒrze eine Schwester ist und vor allem, dass der Code funktioniert
- Sie können Fragen stellen, nach anderen Wegen suchen und sogar alternative Lösungen finden
- Sei nicht still! Wenn Sie anderen ein Problem erklÀren, entstehen bessere Lösungen (Rubber Duck Driven Developent)
Vielen Dank fĂŒr Ihre Aufmerksamkeit