Uwaga! Trwają prace nad nową wersją serwisu. Mogą występować przejściowe problemy z jego funkcjonowaniem. Przepraszam za niedogodności!

🔴 OSTATNI DZIEŃ SPRZEDAŻY (do 23.01) 10-miesięczny kurs front endu 🔴

Podstawy UML – diagramy klas

Pokaż swój program za pomocą diagramów klas w UML

„Jeden obraz jest wart więcej niż tysiąc słów” – to motto przyświecało twórcom diagramów UML. Są to grafiki, które odwzorowują systemy informatyczne. Dzięki nim możemy analizować logikę aplikacji bez konieczności zaglądania w kod. Abyś mógł stworzyć swój pierwszy diagram, wystarczy Ci kartka papieru, ołówek (koniecznie z gumką!) oraz trochę wiedzy o programowaniu obiektowym. A więc do dzieła!

Spis treści

Diagramy UML (Unified Modelling Language) to uniwersalna metoda służąca opisowi systemów informatycznych. Dzięki niej osoby zarządzające projektami mogą w jasny sposób definiować funkcjonalności i logikę aplikacji. Jako że UML jest jasno wyspecyfikowany, programiści nie mają problemu, by przenieść diagramy na działający kod.

Rozróżniamy aż trzynaście rodzajów diagramów UML. My zajmiemy się tylko diagramami klasowymi. Służą one do projektowania struktury klas (pól i metod) oraz ich relacji między sobą. Dzięki nim będziemy mogli łatwiej tworzyć logikę naszych aplikacji, czy analizować wzorce projektowe. Pomyśl tylko, jaka to oszczędność czasu i naszych szarych komórek!

Aby wykorzystać moc diagramów klasowych, poznajmy najpierw ich budulce, czyli:

  • bloki klas,
  • relacje między klasami.

Przykładowy diagram klas, źródło: „Wzorce projektowe. Nowoczesny podręcznik” - Aleksander Shvets

Przykładowy diagram klas, źródło: Aleksander Shvets, Wzorce projektowe. Nowoczesny podręcznik.

Do budowy diagramów polecam Ci darmowe narzędzie – diagrams.net. Kod przedstawiony w przykładach możesz pobrać z tego repozytorium.

 Bloki klas

W UML klasy to po prostu obramowany prostokąt składający się z trzech sekcji:

  • nazwy klasy (lub klasy abstrakcyjnej czy interfejsu),
  • pól (właściwości),
  • metod (akcji).

 Klasy

Spójrz na poniższy przykład klasy Student, która posłuży nam do określenia pól i metod związanych z uczniem.

Diagram klasy na przykładzie klasy Student

  • Pierwsza sekcja określa nazwę klasy, czyli w naszym przypadku to po prostu Student.
  • Kolejna sekcja to pola. Mogą być publiczne, czyli dostępne z każdego miejsca, i oznaczane znakiem +.
    Pola prywatne, do których dostęp mamy jedynie z wnętrza danej klasy, oznaczamy -.
  • Ostatnia sekcja to metody. W naszym przypadku wszystkie są dostępne publicznie. Niektóre zwracają wartość, tak jak getPhone(), którego użycie zwróci typ number. Za to np. setPhone() nie zwraca nic.

 Interfejsy

Interfejs w diagramie klas UML tworzymy przez dodanie słowa <<interface>> nad jego nazwą. Spójrz na przykład interfejsu:

Przykładowy blok interfejsu

Interfejs ma metody, które klasa może implementować. W JavaScripcie interfejsy nie występują, a ich użycie jest umowne.

 Klasy abstrakcyjne

Klasy abstrakcyjne tworzymy dodając <<Abstract>> nad nazwą klasy. Jak widzisz na przykładzie poniżej, oprócz metod mogą one posiadać również pola.

Przykładowy blok klasy abstrakcyjnej

W JavaScripcie klasy abstrakcyjne również występują umownie. Różnią się od zwykłych klas tym, że na ich podstawie nie tworzymy nowych obiektów, a jedynie klasy potomne.

 Relacje między klasami

Umiemy już tworzyć klasy (w tym abstrakcyjne) i interfejsy. To już całkiem sporo!

Żeby stworzyć kompletny diagram UML, brakuje nam jeszcze relacji między klasami. Wyróżniamy sześć rodzajów relacji, które omówimy wraz z przykładami w JavaScripcie i rysunkami.

Będą to:

  • zależność (dependency),
  • relacje związane z rozszerzaniem klas:
    • implementacja (implementation),
    • dziedziczenie (inheritance),
  • relacje związane z interferencją klas:
    • asocjacja (association),
    • agregacja (aggregation),
    • kompozycja (composition).

Diagram: relacje między klasami – od najsłabszej do najsilniejszej

Relacje między klasami – od najsłabszej do najsilniejszej, źródło: Aleksander Shvets, Wzorce projektowe. Nowoczesny podręcznik.

Zwróć uwagę, że każda relacja jest oznaczona w inny sposób. Te powiązane z rozszerzaniem klas mają trójkątny grot, natomiast odnoszące się do interferencji – strzałkę. Poza tym zaznaczamy je linią ciągłą z wyjątkiem implementacji i zależności, które reprezentuje linia przerywana. Nauczmy się, co konkretnie oznaczają poszczególne relacje!

 Zależność (dependency)

Zależność to najsłabszy rodzaj relacji pomiędzy obiektami. Mówimy o niej, jeśli pewne zmiany w jednym z obiektów mogą skutkować zmianami w drugim.

Diagram zależności (dependency)

Tak słabych zależności raczej nie prezentujemy na diagramach. Tak naprawdę każda relacja jest zależnością, więc zdecydowanie lepszym pomysłem jest doprecyzowanie, jaki to rodzaj zależności. Duża liczba wprowadzonych na rysunek zależności spowodowałaby jego zaciemnienie i niemałą konsternację czytających.

 Relacje związane z rozszerzaniem klas

Implementacja (implementation)

Implementacja to relacja, w której jedna z klas definiuje pola lub metody zadeklarowane w interfejsie. Po prostu jest to dziedziczenie po interfejsie.

W przykładzie, który Ci pokażę, klasa Student implementuje interfejs Smart określający pilnego ucznia.

Diagram implementacji

Przykład implementacji w kodzie:

class SmartInterface {
    learn() {
        console.log('Learning...');
    }

    passTest() {
        console.log('Passing test...');
    }

    beEarly() {
        console.log('Wake up at 6 am...');
    }
}

class Student extends SmartInterface {
    #school;
    #subjects;

    constructor(school, subjects) {
        super();
        this.#school = school;
        this.#subjects = subjects;
    }
}

const student = new Student('MIT', ['Math', 'Physics', 'Chemistry']);
// dzięki rozszerzeniu interfejsu mamy dostęp do metody learn()
student.learn();

W artykule wprowadzającym do wzorców projektowych wspominałem, że pisząc kod obiektowy w JavaScripcie, musimy iść na pewne ustępstwa. JS nie posiada interfejsów, stąd zgodnie z konwencją umawiamy się na klasę SmartInterface, która taki interfejs symuluje.

Dziedziczenie (inheritance)

Dziedziczenie to relacja, w której klasa potomna dziedziczy wszystko, co związane z klasą nadrzędną. Dodatkowo taka klasa może rozszerzyć swojego rodzica.

Prosto rzecz ujmując – jest to relacja analogiczna do implementacji, z tym że dziedziczymy po klasie (oraz interfejsach, które sama implementuje).

Zaprezentuję Ci przykład, w którym klasa Teacher dziedziczy po klasie abstrakcyjnej Person.

Diagram przedstawiający dziedziczenie

Dziedziczenie oparte na powyższym diagramie będzie prezentować się następująco:

class AbstractPerson {
    #name;
    #age;

    constructor(name, age) {
        this.#name = name;
        this.#age = age;
    }

    getName() {
        return this.#name;
    }

    setName(name) {
        this.#name = name;
    }

    getAge() {
        return this.#age;
    }

    setAge(age) {
        this.#age = age;
    }

    isAdult() {
        return this.#age >= 18;
    }
}

class Teacher extends AbstractPerson {
    #schoolName;
    #students;

    constructor(name, age, schoolName, students) {
        super(name, age);
        this.#schoolName = schoolName;
        this.#students = students;
    }

    getSchool() {
        return this.#schoolName;
    }
}

const teacher = new Teacher('John', 30, true, 'MIT', ['John', 'Mary', 'Peter']);
console.log(teacher.getName());

Dziedziczenie może opierać się na każdej klasie, niekoniecznie abstrakcyjnej.

 Relacje związane z interferencją klas

Asocjacja (association)

Asocjacja to najluźniejsza relacja związana z wzajemnym oddziaływaniem klas. Oznacza ona, że jeden obiekt chwilowo wykorzystuje inny lub wchodzi z nim w jakąś interakcję. Obiekty mogą istnieć niezależnie od siebie.

Spójrz na przykład klasy Teacher. W tej metodzie Teacher może nauczać jakiegoś przedmiotu (klasa Subject), wykorzystując metodę teach.

Diagram przedstawiający asocjację

Zobacz przykład zastosowania asocjacji w kodzie:

class Subject {
    #name;
    #level;

    constructor(name, level) {
        this.#name = name;
        this.#level = level;
    }

    getName() {
        return this.#name;
    }

    getLevel() {
        return this.#level;
    }
}

class Teacher {
    #name;

    constructor(name) {
        this.#name = name;
    }

    teach(subject) {
        if (!subject instanceof Subject) {
            throw new Error('Invalid subject');
        }

        console.log(
            `I'm teaching ${subject.getName()},
            at level ${subject.getLevel()}`
        );
    }
}

const subject = new Subject('Math', 1);
const teacher = new Teacher('Matt');

teacher.teach(subject);

Agregacja (aggregation)

Agregacja to rodzaj asocjacji. Różni się od niej tym, że jeden obiekt służy za kontener dla innych obiektów. Dzięki zadeklarowaniu przekazywanego obiektu w konstruktorze, mamy możliwość operacji na przekazanym obiekcie w całym ciele. Mimo to oba obiekty mogą istnieć niezależnie od siebie.

Wiem, że ta definicja może wydawać się trudna, dlatego spójrz na poniższy przykład:

Diagram przedstawiający agregację

Jak widzisz, powyższa agregacja różni się od asocjacji tym, że klasa Teacher stanowi kontener na obiekty stworzone za pomocą klasy Subject. Mimo to Subject stanowi osobny byt. Nawet jeśli usuniemy nauczyciela, to przedmioty, których może uczyć, nadal będą istnieć w programie.

class Subject {
    #name;
    #level;

    constructor(name, level) {
        this.#name = name;
        this.#level = level;
    }

    getName() {
        return this.#name;
    }

    getLevel() {
        return this.#level;
    }
}

class Teacher {
    #subjects;

    constructor(subjects) {
        this.#subjects = subjects;
    }

    getSubjects() {
        return this.#subjects;
    }

    addSubject(subject) {
        if (!subject instanceof Subject) {
            throw new Error('Invalid subject');
        }

        this.#subjects.push(subject);
    }

    removeSubject(subject) {
        if (!subject instanceof Subject) {
            throw new Error('Invalid subject');
        }

        this.#subjects.filter(s => s !== subject);
    }

    teach(subject) {
        if (!subject instanceof Subject) {
            throw new Error('Invalid subject');
        }

        console.log(
            `I'm teaching ${subject.getName()},
            at level ${subject.getLevel()}`
        );
    }
}

const subject = new Subject('Math', 1);
const anotherSubject = new Subject('English', 2);

const teacher = new Teacher([subject]);
teacher.addSubject(anotherSubject);
console.log(teacher.getSubjects());

Jak widzisz, konstruktor klasy Teacher przechowuje informacje o przedmiotach (Subject). Dzięki temu możemy np. usunąć konkretny przedmiot z tablicy występującej w obiekcie teacher.

Kompozycja (composition)

Kompozycja to ostatni i najsilniejszy rodzaj relacji, jakie występują między obiektami. Kompozycja stanowi specjalny rodzaj agregacji.

Jak sama nazwa wskazuje – dany obiekt „komponuje się” z innych, czyli składa się z nich. Usunięcie obiektu nadrzędnego spowoduje usunięcie obiektów, które wchodzą w jego skład. Obiekty te nie mogą istnieć niezależnie od siebie.

Jako przykładu moglibyśmy użyć naszego ciała, w którego skład wchodzi układ nerwowy. Bez ciała nie moglibyśmy mówić o układzie nerwowym i vice versa. Aby zaprezentować Ci przykład kompozycji, posłużę się klasą School, która będzie składać się (komponować) z obiektów powstałych na bazie klasy Classroom.

Diagram przedstawiający kompozycję

Aby poznać pełną różnicę między agregacją a kompozycją, spójrz na przykład w kodzie. Zwróć uwagę na konstruktor klasy School.

class Classroom {
    #name;
    #capacity;
    #color;

    constructor(name, capacity, color) {
        this.#name = name;
        this.#capacity = capacity;
        this.#color = color;
    }

    getName() {
        return this.#name;
    }

    setName(name) {
        this.#name = name;
    }

    setCapacity(capacity) {
        this.#capacity = capacity;
    }

    paintWalls(color) {
        this.#color = color;
    }
}

class School {
    #classrooms;

    constructor() {
        this.#classrooms = [
            new Classroom('01', 20, 'white'),
            new Classroom('02', 30, 'yellow'),
            new Classroom('03', 10, 'blue')
        ];
    }

    getClassrooms() {
        return this.#classrooms;
    }

    addClassroom(classroom) {
        if (!classroom instanceof Classroom) {
            throw new Error('Invalid classroom');
        }

        this.#classrooms.push(classroom);
    }

    renovateClassroom(name, color) {
        const classroom = this.#classrooms.find(c => c.getName() === name);
        if (!classroom) {
            throw new Error('Classroom not found');
        }
        classroom.paintWalls(color);
    }
}

const school = new School();
school.renovateClassroom('01', 'red');
console.log(school);

Możesz zauważyć, że nowe obiekty z Classroom powstają bezpośrednio w konstruktorze School. Ta klasa stanowi kontener na obiekty. Usunięcie jej spowoduje usunięcie elementów wchodzących w jej skład.

Dzięki istnieniu metody renovateClassroom możemy wpływać na obiekty wchodzące w skład kontenera School.

 Podsumowanie

Poznałeś już cztery filary programowania obiektowego oraz diagramy UML. Dzięki temu wiesz:

  • jak tworzyć klasy, interfejsy i klasy abstrakcyjne,
  • jak korzystać z hermetyzacji, polimorfizmu, abstrakcji i dziedziczenia,
  • jak przedstawić model swojego programu za pomocą diagramów klasowych.

Wykorzystaj tę wiedzę – spróbuj napisać obiektowo prosty projekt. Skorzystaj z diagramu UML, by zobrazować logikę swojej aplikacji. To na pewno zrobi wrażenie na Twoim (przyszłym) pracodawcy!

Diagram przedstawiający wszystkie relacje między klasami

 Źródła

Udostępnij ten artykuł:

Mentoring to efektywna nauka pod okiem doświadczonej osoby, która:

  • przekazuje Ci swoją wiedzę i nadzoruje Twoje postępy w zdobywaniu umiejętności,
  • uczy Cię dobrych praktyk i wyłapuje złe nawyki,
  • wspiera Twój rozwój i zwiększa zaangażowanie w naukę.