Enkapsulacja — obiekt pilnuje swoich zasad

Reguła „nie może być minus pięciu kabli” zapisana w trzech miejscach programu to trzy okazje do pomyłki. Zapisana w jednym — w środku obiektu — nie da się ominąć.

C# enkapsulacja reguły obiektu IReadOnlyList 45–90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Wypiszesz reguły, których obiekt ma pilnować przez cały czas.
  • Zamkniesz stan w polach prywatnych i udostępnisz go tylko do odczytu.
  • Sprawdzisz dane w konstruktorze, żeby obiekt nie powstał w złym stanie.
  • Rozpoznasz wyciek prywatnej kolekcji i zabezpieczysz go.
  • Wyjaśnisz, dlaczego { get; set; } dla każdego pola to nie jest enkapsulacja.

Przygotowanie: lekcje 01–41. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.

TEORIA

Reguła, której pilnuje każdy — czyli nikt

W szkolnym magazynie nie może być minus pięciu kabli. Bez enkapsulacji reguła wygląda tak:

reguła w trzech miejscach, w trzech wersjach
class Magazyn
{
    public int Sztuki;
}

// w jednym miejscu programu
if (magazyn.Sztuki - 3 >= 0) magazyn.Sztuki -= 3;

// w drugim miejscu ktos zapomnial
magazyn.Sztuki -= 10;

// w trzecim sprawdzil, ale inaczej
if (magazyn.Sztuki > 3) magazyn.Sztuki -= 3;   // blad o jeden

Trzy miejsca, trzy różne sprawdzenia, jedno z błędem. Przy dziesiątym wywołaniu nikt już nie wie, która wersja jest właściwa.

Enkapsulacja to przeniesienie reguły do środka obiektu — tam, gdzie leżą dane, których dotyczy.

reguła w jednym miejscu
class Magazyn
{
    private int sztuki;

    public int Sztuki => sztuki;

    public bool Wydaj(int ile)
    {
        if (ile <= 0 || ile > sztuki)
        {
            return false;
        }

        sztuki -= ile;
        return true;
    }
}

Teraz nie ma żadnej drogi, która omija sprawdzenie. Nie dlatego, że wszyscy o nim pamiętają, tylko dlatego, że innej drogi nie ma.

Dwa słowa, które warto rozróżniać

Ukrywanie danych to zamknięcie pól przez private — środek techniczny. Enkapsulacja to pomysł: obiekt sam odpowiada za spójność swojego stanu. Można ukryć pola i nie osiągnąć enkapsulacji — wystarczy dorobić do każdego właściwość { get; set; }.

TEORIA

Czego obiekt pilnuje przez cały czas

Zanim napiszesz klasę, wypisz zdania, które muszą być prawdziwe zawsze, od utworzenia obiektu aż po koniec programu. Dla magazynu:

  • liczba sztuk nigdy nie jest ujemna,
  • liczba sztuk nigdy nie przekracza pojemności,
  • nazwa towaru nigdy nie jest pusta.

Takie zdanie nazywamy regułą obiektu. Enkapsulacja polega na tym, żeby nie dało się jej złamać — ani przez pomyłkę, ani celowo.

MiejsceCo robi
konstruktorustala stan początkowy zgodny z regułami; odrzuca dane, z których nie da się zbudować sensownego obiektu
metodyjedyna droga zmiany stanu; każda sprawdza, czy po zmianie reguły nadal są spełnione
właściwościodczyt bez ograniczeń, zapis tylko tam, gdzie nie łamie reguł
pola prywatneprzechowują stan i są niedostępne z zewnątrz
definicja typu
class Magazyn
{
    private int sztuki;
    private readonly int pojemnosc;

    public string Nazwa { get; }

    public Magazyn(string nazwa, int pojemnosc)
    {
        if (string.IsNullOrWhiteSpace(nazwa))
        {
            throw new ArgumentException("Nazwa nie moze byc pusta.", nameof(nazwa));
        }
        if (pojemnosc < 1)
        {
            throw new ArgumentOutOfRangeException(nameof(pojemnosc));
        }

        Nazwa = nazwa;
        this.pojemnosc = pojemnosc;
    }
}

Obiekt nie powstaje w złym stanie

Sprawdzenie w konstruktorze to najtańsze zabezpieczenie w całej klasie. Jeśli obiekt nie może powstać z błędnych danych, żadna metoda nie musi później sprawdzać, czy nazwa nie jest pusta.

TEORIA

Prywatne pole, które i tak wycieka

Najczęstszy błąd na tym etapie: pole jest prywatne, a mimo to każdy może je zmienić.

Program.cs
class Kurs
{
    private readonly List<string> uczestnicy = new List<string>();

    public List<string> Uczestnicy => uczestnicy;      // WYCIEK
}

Kurs k = new Kurs();
k.Uczestnicy.Add("ktokolwiek");     // dodano z pominieciem wszelkich regul
k.Uczestnicy.Clear();               // i wyczyszczono cala liste

Właściwość oddaje tę samą listę, a nie jej kopię — dlatego wywołujący ma pełną władzę nad wnętrzem obiektu. Słowo readonly nie pomaga: chroni referencję, nie zawartość (lekcja 43).

definicja typu
class Kurs
{
    private readonly List<string> uczestnicy = new List<string>();
    private const int LIMIT = 12;

    public int Ilu => uczestnicy.Count;
    public bool Pelny => uczestnicy.Count >= LIMIT;

    // tylko do odczytu - nie da sie dodac ani usunac
    public IReadOnlyList<string> Uczestnicy => uczestnicy;

    public bool Zapisz(string imie)
    {
        if (string.IsNullOrWhiteSpace(imie)) return false;
        if (Pelny) return false;
        if (uczestnicy.Contains(imie)) return false;

        uczestnicy.Add(imie);
        return true;
    }
}

Trzy sposoby na wyciek

1. Oddanie kolekcji jako List<T> zamiast IReadOnlyList<T>. 2. Oddanie tablicy — tablicy nie da się zabezpieczyć, trzeba zwrócić kopię przez ToArray. 3. Oddanie obiektu, którego pola są publiczne. W każdym z tych przypadków wnętrze wypływa na zewnątrz mimo słowa private.

PRZYKŁAD

Przykład: magazyn, którego nie da się oszukać

Program.cs
using System.Collections.Generic;

class Magazyn
{
    private int sztuki;
    private readonly List<string> historia = new List<string>();
    private readonly int pojemnosc;

    public string Nazwa { get; }
    public int Sztuki => sztuki;
    public bool Pusty => sztuki == 0;
    public IReadOnlyList<string> Historia => historia;

    public Magazyn(string nazwa, int pojemnosc, int stanPoczatkowy)
    {
        if (string.IsNullOrWhiteSpace(nazwa))
        {
            throw new ArgumentException("Nazwa nie moze byc pusta.", nameof(nazwa));
        }
        if (pojemnosc < 1 || stanPoczatkowy < 0 || stanPoczatkowy > pojemnosc)
        {
            throw new ArgumentOutOfRangeException(nameof(pojemnosc));
        }

        Nazwa = nazwa;
        this.pojemnosc = pojemnosc;
        sztuki = stanPoczatkowy;
        historia.Add($"utworzono ze stanem {stanPoczatkowy}");
    }

    public bool Przyjmij(int ile)
    {
        if (ile <= 0 || sztuki + ile > pojemnosc)
        {
            historia.Add($"odrzucono przyjecie {ile}");
            return false;
        }

        sztuki += ile;
        historia.Add($"przyjeto {ile}, stan {sztuki}");
        return true;
    }

    public bool Wydaj(int ile)
    {
        if (ile <= 0 || ile > sztuki)
        {
            historia.Add($"odrzucono wydanie {ile}");
            return false;
        }

        sztuki -= ile;
        historia.Add($"wydano {ile}, stan {sztuki}");
        return true;
    }
}

class Program
{
    static void Main()
    {
        Magazyn m = new Magazyn("Kable HDMI", 20, 5);

        m.Przyjmij(10);
        m.Wydaj(3);
        m.Wydaj(100);      // odrzucone
        m.Przyjmij(50);    // odrzucone - ponad pojemnosc

        Console.WriteLine($"{m.Nazwa}: {m.Sztuki} szt., pusty: {m.Pusty}");
        Console.WriteLine();

        foreach (string wpis in m.Historia)
        {
            Console.WriteLine("  " + wpis);
        }

        // m.Sztuki = 999;              // CS0200 - brak settera
        // m.Historia.Add("oszustwo");  // CS1061 - IReadOnlyList nie ma Add
    }
}
wynik w konsoli
Kable HDMI: 12 szt., pusty: False

  utworzono ze stanem 5
  przyjeto 10, stan 15
  wydano 3, stan 12
  odrzucono wydanie 100
  odrzucono przyjecie 50

Co dzieje się po kolei

  • Konstruktor odrzuca dane, z których nie da się zbudować sensownego magazynu. Dzięki temu żadna metoda nie musi później sprawdzać nazwy ani pojemności.
  • Obie metody zwracają bool zamiast zgłaszać wyjątek — odmowa wydania to normalna sytuacja, a nie błąd wykonania (lekcja 35).
  • Historia rośnie także przy odmowach. To też jest stan obiektu i też jest chroniony.
  • Sztuki i Pusty są tylko do odczytu, a Historia oddaje listę jako IReadOnlyList, więc nikt z zewnątrz nic do niej nie dopisze.
  • Dwa zakomentowane wiersze to sedno lekcji: obie drogi obejścia reguł są zamknięte już na etapie kompilacji.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementRola w enkapsulacjiUwagi
pole privateprzechowuje stanJedyne miejsce, w którym stan naprawdę leży.
readonlyreferencja nie do podmianyChroni wskazanie, nie zawartość kolekcji.
conststała znana przy kompilacjiLimity i progi — private const int LIMIT = 12;.
właściwość =>odczyt bez zapisuNajprostszy sposób na udostępnienie stanu.
IReadOnlyList<T>kolekcja tylko do odczytuDa się przejść foreach, nie da się zmienić.
metoda zwracająca booloperacja z odmowąGdy odmowa jest normalną sytuacją.
wyjątek w konstruktorzeobiekt nie powstaje w złym stanieDla danych, z których nie da się zbudować obiektu.
ToArray() / new List<T>(…)kopia zamiast oryginałuGdy trzeba oddać coś, co wywołujący może zmieniać.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
Pole prywatne plus { get; set; } dla każdegoZmieniono zapis, nie zmieniono niczego — reguły nadal da się ominąć.
Zwrócenie List<T> z właściwościWywołujący może dodawać i usuwać elementy. Zwróć IReadOnlyList<T> albo kopię.
Zwrócenie tablicy prywatnejTablicy nie da się zabezpieczyć — zwróć kopię przez ToArray().
Sprawdzenia w Main zamiast w klasieDrugie miejsce wywołania na pewno o nich zapomni.
Konstruktor przyjmujący każde daneObiekt powstaje w stanie, którego reguły nie dopuszczają, i psuje się później w losowym miejscu.
readonly na kolekcji traktowane jak ochronaChroni tylko przed podmianą listy; elementy nadal można dodawać.
Metoda cicho ignorująca błędne daneWywołujący nie wie, czy operacja się udała. Zwróć bool albo zgłoś wyjątek.
ZADANIA

Zadania

ZAD 1Zamknij skarbonkę★☆☆

Napisz klasę Skarbonka z prywatnym polem monet, właściwością tylko do odczytu i metodą Wloz(int) przyjmującą od 1 do 10 monet. Sprawdź, że z Main nie da się ustawić liczby monet wprost.

ZAD 2Reguły w konstruktorze★☆☆

Do klasy Skarbonka dodaj konstruktor przyjmujący pojemność. Wartość mniejsza od jednego ma zgłaszać wyjątek. Sprawdź oba przypadki.

ZAD 3Wypisz reguły★★☆

Dla klasy Konto wypisz w komentarzu wszystkie zdania, które muszą być prawdziwe zawsze. Potem napisz klasę tak, żeby żadnej z nich nie dało się złamać, i przy każdej metodzie dopisz, której reguły pilnuje.

ZAD 4Kolekcja bez wycieku★★☆

Napisz klasę Kurs z prywatną listą uczestników, limitem miejsc i metodą Zapisz(string) odrzucającą puste imiona, duplikaty i zapisy ponad limit. Udostępnij listę tak, żeby dało się ją przejść pętlą, ale nie dało się nic dodać. Sprawdź, jaki komunikat daje próba dodania.

ZAD 5Odmowa, o której wiadomo★★☆

Przerób metody klasy Kurs tak, żeby wywołujący dowiedział się, dlaczego zapis się nie udał — nie tylko że się nie udał. Zaproponuj dwa rozwiązania i w komentarzu uzasadnij wybór jednego z nich.

ZAD 6Wypożyczalnia★★★

Napisz klasę Wypozyczalnia przechowującą listę sprzętu i zbiór numerów wypożyczonych. Z zewnątrz mają być dostępne wyłącznie: wypożyczenie po numerze, zwrot, sprawdzenie dostępności i przejrzenie listy dostępnego sprzętu. Żadna operacja nie może pozwolić na wypożyczenie tego samego sprzętu dwa razy ani na zwrot czegoś, czego nikt nie wypożyczył. Na koniec spróbuj celowo złamać każdą regułę z zewnątrz i zapisz, co ci to uniemożliwiło.

PODSUMOWANIE

Co trzeba zapamiętać

  • Enkapsulacja to nie samo private, tylko odpowiedzialność obiektu za własny stan.
  • Reguła obiektu to zdanie prawdziwe od utworzenia do końca istnienia obiektu.
  • Konstruktor nie pozwala powstać obiektowi w stanie łamiącym reguły.
  • Metody są jedyną drogą zmiany stanu i każda sprawdza, czy reguły nadal obowiązują.
  • Zwrócenie prywatnej kolekcji jako List<T> otwiera wnętrze obiektu mimo private.
  • Zamiana pól na właściwości { get; set; } niczego nie zabezpiecza.

Dokumentacja: Microsoft Learn — temat tej lekcji.