Dlaczego używanie konstruktorów w ustawieniach nie stało się powszechnym wzorcem?

23

Akcesoria i modyfikatory (znane również jako setery i gettery) są przydatne z trzech głównych powodów:

  1. Ograniczają dostęp do zmiennych.
    • Na przykład można uzyskać dostęp do zmiennej, ale nie można jej modyfikować.
  2. Sprawdzają poprawność parametrów.
  3. Mogą powodować pewne działania niepożądane.

Uniwersytety, kursy online, samouczki, artykuły na blogach i przykłady kodu w Internecie kładą nacisk na znaczenie akcesoriów i modyfikatorów, które w dzisiejszych czasach są niemal „obowiązkowe” dla kodu. Można je więc znaleźć, nawet jeśli nie dostarczają żadnej dodatkowej wartości, jak na przykład poniższy kod.

public class Cat {
    private int age;

    public int getAge() {
        return this.age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

To powiedziawszy, bardzo często można znaleźć bardziej przydatne modyfikatory, te, które faktycznie sprawdzają parametry i rzucają wyjątek lub zwracają wartość logiczną, jeśli podano nieprawidłowe dane wejściowe, coś takiego:

/**
 * Sets the age for the current cat
 * @param age an integer with the valid values between 0 and 25
 * @return true if value has been assigned and false if the parameter is invalid
 */
public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

Ale nawet wtedy prawie nigdy nie widzę, aby modyfikatory były wywoływane z konstruktora, więc najczęstszym przykładem prostej klasy, z którą mam do czynienia, jest:

public class Cat {
    private int age;

    public Cat(int age) {
        this.age = age;
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

Ale można by pomyśleć, że to drugie podejście jest znacznie bezpieczniejsze:

public class Cat {
    private int age;

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    public int getAge() {
        return this.age;
    }

    /**
     * Sets the age for the current cat
     * @param age an integer with the valid values between 0 and 25
     * @return true if value has been assigned and false if the parameter is invalid
     */
    public boolean setAge(int age) {
        //Validate your parameters, valid age for a cat is between 0 and 25 years
        if(age > 0 && age < 25) {
            this.age = age;
            return true;
        }
        return false;
    }

}

Czy widzisz podobny wzorzec w swoim doświadczeniu, czy tylko ja mam pecha? A jeśli tak, to co według ciebie jest przyczyną? Czy jest oczywista wada używania modyfikatorów konstruktorów, czy też są one po prostu uważane za bezpieczniejsze? Czy to coś innego?

Vlad Spreys
źródło
1
@stg, dziękuję, interesujący punkt! Czy mógłbyś go rozwinąć i udzielić odpowiedzi na przykład złej rzeczy, która może się zdarzyć?
Vlad Spreys,
8
@stg W rzeczywistości używanie antykoncepcji w konstruktorze jest anty-wzorcem, a wiele narzędzi do zarządzania jakością kodu wskaże to jako błąd. Nie wiesz, co zrobi przesłonięta wersja, i może robić paskudne rzeczy, takie jak pozwalanie „temu” uciec przed zakończeniem działania konstruktora, co może prowadzić do różnego rodzaju dziwnych problemów.
Michał Kosmulski
1
@gnat: Nie sądzę, że jest to duplikat metody. Czy metody klasy powinny wywoływać własne metody pobierające i ustawiające? , ponieważ natura wywołań konstruktora jest wywoływana na obiekcie, który nie jest w pełni uformowany.
Greg Burghardt
3
Zauważ też, że twoja „walidacja” po prostu pozostawia wiek niezainicjowany (lub zero, lub… coś innego, w zależności od języka). Jeśli chcesz, aby konstruktor zawiódł, musisz sprawdzić wartość zwracaną i zgłosić wyjątek w przypadku niepowodzenia. W tej chwili walidacja wprowadziła inny błąd.
Bezużyteczne

Odpowiedzi:

37

Bardzo ogólne rozumowanie filozoficzne

Zazwyczaj prosimy, aby konstruktor zapewnił (jako warunki dodatkowe) pewne gwarancje dotyczące stanu zbudowanego obiektu.

Zazwyczaj spodziewamy się również, że metody instancji mogą założyć (jako warunki wstępne), że te gwarancje już istnieją, gdy są wywoływane, i muszą tylko upewnić się, że ich nie złamią.

Wywołanie metody instancji z wnętrza konstruktora oznacza, że ​​niektóre lub wszystkie z tych gwarancji mogły jeszcze nie zostać ustanowione, co utrudnia rozumowanie, czy warunki wstępne metody instancji są spełnione. Nawet jeśli dobrze to zrobisz, może być bardzo delikatny w obliczu np. zmiana kolejności wywołań instancji lub innych operacji.

Języki różnią się także sposobem, w jaki przetwarzają wywołania metod instancji, które są dziedziczone z klas podstawowych / zastępowane przez podklasy, podczas gdy konstruktor nadal działa. To dodaje kolejną warstwę złożoności.

Konkretne przykłady

  1. Twój własny przykład tego, jak według ciebie powinno to wyglądać, jest błędny:

    public Cat(int age) {
        //Use the modifier instead of assigning the value directly.
        setAge(age);
    }

    nie sprawdza to zwracanej wartości z setAge. Najwyraźniej wezwanie setera nie jest wcale gwarancją poprawności.

  2. Bardzo łatwe błędy, takie jak zależność od kolejności inicjalizacji, takie jak:

    class Cat {
      private Logger m_log;
      private int m_age;
    
      public void setAge(int age) {
        // FIXME temporary debug logging
        m_log.write("=== DEBUG: setting age ===");
        m_age = age;
      }
    
      public Cat(int age, Logger log) {
        setAge(age);
        m_log = log;
      }
    };

    gdzie moje tymczasowe logowanie zepsuło wszystko. Ups!

Istnieją również języki takie jak C ++, w których wywoływanie settera z konstruktora oznacza zmarnowaną domyślną inicjalizację (której w przypadku niektórych zmiennych składowych warto unikać)

Prosta propozycja

Prawdą jest, że większość kodu nie jest napisana w ten sposób, ale jeśli chcesz zachować swój konstruktor w czystości i przewidywalności, i nadal używać logiki przed i po warunku, lepszym rozwiązaniem jest:

class Cat {
  private int m_age;
  private static void checkAge(int age) {
    if (age > 25) throw CatTooOldError(age);
  }

  public void setAge(int age) {
    checkAge(age);
    m_age = age;
  }

  public Cat(int age) {
    checkAge(age);
    m_age = age;
  }
};

lub nawet lepiej, jeśli to możliwe: zakoduj ograniczenie w typie właściwości i poproś o sprawdzenie jego własnej wartości przy przypisaniu:

class Cat {
  private Constrained<int, 25> m_age;

  public void setAge(int age) {
    m_age = age;
  }

  public Cat(int age) {
    m_age = age;
  }
};

I wreszcie, dla pełnej kompletności, samo-sprawdzające się opakowanie w C ++. Zauważ, że chociaż nadal wykonuje żmudne sprawdzanie poprawności, ponieważ ta klasa nie robi nic więcej , jest stosunkowo łatwa do sprawdzenia

template <typename T, T MAX, T MIN=T{}>
class Constrained {
    T val_;
    static T validate(T v) {
        if (MIN <= v && v <= MAX) return v;
        throw std::runtime_error("oops");
    }
public:
    Constrained() : val_(MIN) {}
    explicit Constrained(T v) : val_(validate(v)) {}

    Constrained& operator= (T v) { val_ = validate(v); return *this; }
    operator T() { return val_; }
};

OK, to naprawdę nie jest kompletne, pominąłem różne konstruktory i zadania kopiowania i przenoszenia.

Bezużyteczny
źródło
4
Mocno niedoceniona odpowiedź! Pozwolę sobie dodać tylko dlatego, że PO widział opisaną sytuację w podręczniku, co nie czyni z tego dobrego rozwiązania. Jeśli klasa ma dwa sposoby ustawiania atrybutu (przez konstruktora i ustawiającego), a ktoś chce wymusić ograniczenie dla tego atrybutu, wszystkie oba sposoby muszą sprawdzić ograniczenie, w przeciwnym razie jest to błąd projektowy .
Doc Brown
Czy zastanowiłbyś się więc nad zastosowaniem metod ustawiających w złej praktyce konstruktora, jeśli 1) nie ma logiki w seterach lub 2) logika w seterach jest niezależna od stanu? Nie robię tego często, ale osobiście użyłem metod ustawiających w konstruktorze właśnie z tego drugiego powodu (gdy w ustawieniu występuje pewien rodzaj warunku, który musi zawsze dotyczyć zmiennej
składowej
Jeśli istnieje jakiś warunek, który musi zawsze obowiązywać, jest to logiczne w ustawieniach. Z pewnością uważam, że lepiej, jeśli to możliwe, zakodować tę logikę w elemencie członkowskim, więc jest ona zmuszona do samodzielności i nie może uzyskać zależności od reszty klasy.
Bezużyteczny
13

Kiedy obiekt jest konstruowany, z definicji nie jest w pełni uformowany. Zastanów się, w jaki sposób ustawiający podałeś:

public boolean setAge(int age) {
    //Validate your parameters, valid age for a cat is between 0 and 25 years
    if(age > 0 && age < 25) {
        this.age = age;
        return true;
    }
    return false;
}

Co zrobić, jeśli część walidacji setAge()obejmowała sprawdzenie agenieruchomości, aby upewnić się, że obiekt może się tylko starzeć? Ten stan może wyglądać następująco:

if (age > this.age && age < 25) {
    //...

To wydaje się dość niewinną zmianą, ponieważ koty mogą przemieszczać się w czasie tylko w jednym kierunku. Jedynym problemem jest to, że nie można go użyć do ustawienia wartości początkowej, this.ageponieważ zakłada, że this.agema już prawidłową wartość.

Unikanie seterów w konstruktorach wyjaśnia, że jedyne, co robi konstruktor, to ustawianie zmiennej instancji i pomijanie innych zachowań, które mają miejsce w seterach.

Caleb
źródło
OK ... ale, jeśli nie faktycznie przetestować konstrukcję obiektu i narazić potencjalnych błędów, istnieją potencjalne błędy albo sposób . A teraz masz dwa dwa problemy: masz ukryty potencjalny błąd i musisz wymusić kontrolę przedziału wiekowego w dwóch miejscach.
svidgen
Nie ma problemu, ponieważ w Javie / C # wszystkie pola są zerowane przed wywołaniem konstruktora.
Deduplicator
2
Tak, wywoływanie metod instancji od konstruktorów może być trudne do uzasadnienia i ogólnie nie jest preferowane. Teraz, jeśli chcesz przenieść logikę sprawdzania poprawności do prywatnej, ostatecznej metody klasy / statycznej i wywołać ją zarówno z konstruktora, jak i setera, byłoby dobrze.
Bezużyteczne
1
Nie, metody inne niż instancja unikają podstępu metod instancji, ponieważ nie dotykają częściowo zbudowanej instancji. Oczywiście nadal musisz sprawdzić wynik sprawdzania poprawności, aby zdecydować, czy budowa się powiodła.
Bezużyteczne
1
Ta odpowiedź IMHO nie ma sensu. „Kiedy obiekt jest konstruowany, z definicji nie jest w pełni uformowany” - oczywiście w środku procesu budowy, ale kiedy konstruktor jest gotowy, muszą istnieć wszelkie zamierzone ograniczenia atrybutów, w przeciwnym razie można obejść to ograniczenie. Nazwałbym to poważną wadą projektową.
Doc Brown
6

Kilka dobrych odpowiedzi już tutaj, ale jak dotąd nikt nie zauważył, że pytasz tutaj o dwie rzeczy w jednym, co powoduje pewne zamieszanie. IMHO twój post najlepiej postrzegać jako dwa osobne pytania:

  1. jeśli istnieje sprawdzanie poprawności ograniczenia w seterze, czy nie powinno ono również znajdować się w konstruktorze klasy, aby upewnić się, że nie można go ominąć?

  2. dlaczego nie wywołać Settera bezpośrednio w tym celu z konstruktora?

Odpowiedź na pierwsze pytania jest jednoznaczna „tak”. Konstruktor, jeśli nie zgłosi wyjątku, powinien pozostawić obiekt w poprawnym stanie, a jeśli pewne wartości są zabronione dla niektórych atrybutów, nie ma absolutnie żadnego sensu pozwalać ctorowi na obejście tego.

Jednak odpowiedź na drugie pytanie brzmi zazwyczaj „nie”, pod warunkiem, że nie podejmie się środków, aby uniknąć zastąpienia setera w klasie pochodnej. Dlatego lepszą alternatywą jest zaimplementowanie sprawdzania poprawności ograniczeń w metodzie prywatnej, która może być ponownie użyta zarówno z narzędzia ustawiającego, jak i z konstruktora. Nie zamierzam tutaj powtarzać przykładu @Useless, który pokazuje dokładnie ten projekt.

Doktor Brown
źródło
Nadal nie rozumiem, dlaczego lepiej wyodrębnić logikę z setera, aby konstruktor mógł z niej korzystać. ... Jak to naprawdę różni się od zwykłego dzwonienia do seterów w rozsądnej kolejności? Zwłaszcza biorąc pod uwagę, że jeśli setery są tak proste, jak powinny, to jedyną częścią setera, na którą twój konstruktor w tym momencie się nie powołuje, jest faktyczne ustawienie podstawowego pola ...
svidgen
2
@svidgen: patrz przykład JimmyJames: krótko mówiąc, nie jest dobrym pomysłem stosowanie nadrzędnych metod w ctor, które spowodują problemy. Możesz użyć settera w ctor, jeśli jest on ostateczny i nie wywołuje żadnych innych możliwych do zastąpienia metod. Co więcej, oddzielenie sprawdzania poprawności od sposobu obsługi go, gdy się nie powiedzie, oferuje możliwość obsługi go w inny sposób w ctor i w seterze.
Doc Brown
Jestem teraz jeszcze mniej przekonany! Ale dzięki za wskazanie drugiej odpowiedzi ...
svidgen
2
Mówiąc w rozsądnej kolejności, masz na myśli kruchą, niewidzialną zależność od porządku, którą łatwo przełamują niewinne zmiany , prawda?
Bezużyteczne
@ bezużyteczne cóż, nie ... To zabawne, że sugerujesz, że kolejność wykonywania kodu nie powinna mieć znaczenia. ale tak naprawdę nie o to chodziło. Poza wymyślonymi przykładami, po prostu nie mogę sobie przypomnieć wystąpienia z mojego doświadczenia, w którym pomyślałem, że dobrym pomysłem jest przeprowadzanie walidacji krzyżowych w seterach - tego rodzaju rzeczy niekoniecznie prowadzą do „niewidocznych” wymagań kolejności wywołań . Niezależnie od tego, czy te wywołania występują w konstruktorze ..
svidgen
2

Oto głupiutki kod Java, który pokazuje rodzaje problemów, na które można natknąć się przy użyciu niekończących metod w konstruktorze:

import java.util.regex.Pattern;

public class Problem
{
  public static final void main(String... args)
  {
    Problem problem = new Problem("John Smith");
    Problem next = new NextProblem("John Smith", " ");

    System.out.println(problem.getName());
    System.out.println(next.getName());
  }

  private String name;

  Problem(String name)
  {
    setName(name);
  }

  void setName(String name)
  {
    this.name = name;
  }

  String getName()
  {
    return name;
  }
}

class NextProblem extends Problem
{
  private String firstName;
  private String lastName;
  private final String delimiter;

  NextProblem(String name, String delimiter)
  {
    super(name);

    this.delimiter = delimiter;
  }

  void setName(String name)
  {
    String[] parts = name.split(Pattern.quote(delimiter));

    this.firstName = parts[0];
    this.lastName = parts[1];
  }

  String getName()
  {
    return firstName + " " + lastName;
  }
}

Kiedy to uruchomię, otrzymuję NPE w konstruktorze NextProblem. Jest to oczywiście trywialny przykład, ale sprawy mogą szybko się skomplikować, jeśli masz wiele poziomów dziedziczenia.

Myślę, że głównym powodem, dla którego nie stało się to powszechne, jest fakt, że kod jest trudniejszy do zrozumienia. Osobiście prawie nigdy nie mam metod ustawiających, a moje zmienne składowe są prawie niezmiennie (zamierzone) ostateczne. Z tego powodu wartości muszą być ustawione w konstruktorze. Jeśli używasz niezmiennych obiektów (i jest wiele dobrych powodów, aby to zrobić), pytanie jest dyskusyjne.

W każdym razie dobrym celem jest ponowne użycie sprawdzania poprawności lub innej logiki, można też zastosować metodę statyczną i wywołać ją zarówno z konstruktora, jak i ustawiacza.

JimmyJames
źródło
Jak to w ogóle jest problem z użyciem ustawiaczy w konstruktorze zamiast problemu źle zaimplementowanego dziedziczenia? Innymi słowy, jeśli skutecznie unieważniasz konstruktora podstawowego, dlaczego miałbyś to nazwać !?
svidgen
1
Myślę, że chodzi o to, że przesłanianie setera nie powinno unieważniać konstruktora.
Chris Wohlert,
@ChrisWohlert Ok ... ale jeśli zmienisz logikę settera na konflikt z logiką konstruktora, to będzie problem niezależnie od tego, czy twój konstruktor używa settera. Albo zawiedzie w czasie budowy, albo później, albo w sposób niewidoczny (b / c ominąłeś reguły biznesowe).
svidgen
Często nie wiesz, jak zaimplementowany jest Konstruktor klasy podstawowej (może być gdzieś w bibliotece innej firmy). Przesłonięcie metody nadpisywalnej nie powinno jednak zerwać kontraktu z implementacją podstawową (i zapewne ten przykład robi to, nie ustawiając namepola).
Hulk
@svidgen Problem polega na tym, że wywołanie metod nadpisywalnych z konstruktora zmniejsza użyteczność nadpisywania ich - nie można używać żadnych pól klasy potomnej w przesłoniętych wersjach, ponieważ mogą one nie zostać jeszcze zainicjowane. Co gorsza, nie wolno przekazywać thisodniesienia do jakiejś innej metody, ponieważ nie można być pewnym, że jest ona w pełni zainicjowana.
Hulk
1

To zależy!

Jeśli przeprowadzasz prostą weryfikację lub wywołujesz nieprzyjemne skutki uboczne w seterach, które muszą być trafiane za każdym razem, gdy ustawiana jest właściwość: użyj setera. Alternatywą jest na ogół wyodrębnienie logiki settera z setera i wywołanie nowej metody, a następnie faktycznego zestawu zarówno z settera, jak i konstruktora - co w rzeczywistości jest takie samo, jak użycie settera! (Tyle że teraz trzymasz, co prawda mały, dwuwierszowy seter w dwóch miejscach).

Jednak , jeśli trzeba wykonywać skomplikowanych operacji w celu ustalenia przedmiotu przed konkretnym seter (lub dwa!) Mogłaby z powodzeniem wykonać, nie używać setter.

I w obu przypadkach mieć przypadki testowe . Bez względu na wybraną trasę, jeśli masz testy jednostkowe, będziesz mieć dużą szansę na ujawnienie pułapek związanych z którąkolwiek z tych opcji.


Dodam też, że jeśli moja pamięć z tak odległych czasów jest nawet bardzo dokładna, to właśnie takiego rozumowania nauczyłem się na studiach. I wcale nie jest to niezgodne z udanymi projektami, nad którymi miałem przyjemność pracować.

Gdybym musiał zgadywać, w pewnym momencie przeoczyłem notatkę „czystego kodu”, która zidentyfikowała niektóre typowe błędy, które mogą wynikać z używania setterów w konstruktorze. Ale ze swojej strony nie padłem ofiarą takich błędów ...

Twierdzę więc, że ta konkretna decyzja nie musi być przesadna i dogmatyczna.

svidgen
źródło