Płytkie i głębokie kopiowanie (klonowanie) obiektów

Wprowadzenie

Podczas programowania często spotykamy się z sytuacją, w której potrzebujemy powielić niektóre z naszych obiektów, bo na przykład chcemy wprowadzić w swoim programie podstawową funkcjonalność kopiowania i wklejania pewnych elementów. Możemy sobie wyobrazić prostą aplikację typu Paint, gdzie coś rysujemy i oczywiste jest, że możliwość kopiowania będzie tutaj z pewnością często wykorzystywana.

Na pierwszy rzut oka mogłoby się wydawać, że nie ma z tym żadnego problemu, jednak za chwilę pokażę kilka najczęściej popełnianych błędów, które później mogą wprowadzić nas w tarapaty i spowodują, że będziemy musieli poświęcić trochę czasu na debugowanie naszej aplikacji.

Na potrzeby tej lekcji stwórzmy sobie dwie proste klasy, które przechowują informacje o odcinku.

Punkt.java

public class Punkt {
     private int x;
     private int y;
 
     public Punkt(int x, int y) {
         this.x = x;
         this.y = y;
     }
 
     public int getX() {
         return x;
     }
     public void setX(int x) {
         this.x = x;
     }
     public int getY() {
         return y;
     }
     public void setY(int y) {
         this.y = y;
     }
 }

Odcinek.java

public class Odcinek {
     private Punkt start;
     private Punkt koniec;
 
     public Odcinek(Punkt start, Punkt koniec) {
         this.start = start;
         this.koniec = koniec;
     }
 
     public Punkt getStart() {
         return start;
     }
     public void setStart(Punkt start) {
         this.start = start;
     }
     public Punkt getKoniec() {
         return koniec;
     }
     public void setKoniec(Punkt koniec) {
         this.koniec = koniec;
     }
 }

Teraz zobaczmy jak początkowo większość osób podchodzi do kopiowania obiektów w Javie. Jak zapewne większość osób wie w Javie obiekty można porównywać na dwa sposoby:

a) za pomocą operatora ==. Sprawdza on równość fizyczna - porównuje referencje, co jednocześnie daje informację o równości wewnętrznej obiektu na który dana referencja wskazuje

b) za pomocą metody equals() - ta oprócz porównania referencji sprawdza równość pól obiektu. Mając dwa osobne obiekty klasy Punkt, ale o tych samych współrzędnych wynik porównania również pokaże true, mimo iż porównanie za pomocą == zwróci false.

Oba powyższe sposoby na porównywanie obiektów wykorzystamy, aby zobrazować problemy związane z kopiowaniem.

Kurs Java

Kopia jedynie referencji głównego obiektu

Przyklad1.java

public class Przyklad1 {
     public static void main(String[] args) {
         Punkt punkt1 = new Punkt(10, 10);
         Punkt punkt2 = punkt1;
         Punkt punkt3 = new Punkt(10, 10);
         boolean porownanie1 = punkt1==punkt2;
         boolean porownanie2 = punkt1==punkt3;
 
         System.out.println("Porownanie1: "+porownanie1);
         System.out.println("Porownanie2: "+porownanie2);
         wyswietl(punkt1, punkt2, punkt3);
 
         punkt2.setX(11);
         punkt3.setX(12);
         wyswietl(punkt1, punkt2, punkt3);
     }
 
     public static void wyswietl(Punkt... punkty) {
         int size = punkty.length;
         for(int i=0; i<size; i++) {
             System.out.println("Punkt "+i+" x:"+punkty[i].getX()+" ; y:"+punkty[i].getY());
         }
         System.out.println();
     }
 }

Na pierwszy rzut oka wiele osób może pomyśleć, że utworzyliśmy trzy obiekty klasy Punkt punkt1, punkt2, punkt3, ale o takich samych współrzędnych i że stanowią one niezależne od siebie byty. Wynik uruchomienia programu pokazuje, że dwa pierwsze obiekty wskazują dokładnie na ten sam obiekt (porównanie referencji zwraca true), natomiast punkt3 faktycznie jest już niezależnym obiektem (porównanie referencji zwraca false).

Kopiowanie referencji obiektów

Wiele osób nadal może nie widzieć w tym problemu jednak problem widać gołym okiem, gdy zmieniamy współrzędne punktów. W naszym programie zmieniamy współrzędną x obiektów punkt2 i punkt3 . Po uruchomieniu widać jednak, że zmieniły się współrzędne wszystkich trzech punktów! Ponieważ referencje punkt1 i punkt2 wskazują na ten sam obiekt, to niezależnie, czy zmian dokonamy poprzez punkt1, czy punkt2 to wpłyną one na ten sam obiekt.

Łatwo wyobrazić sobie sytuację, gdzie mają aplikację, w której coś rysujemy kopiowanie referencji nie daje nam tak naprawdę nic, ponieważ wskazują one wciąć na jeden obiekt.

W powyższej sytuacji można by sobie poradzić z tym problemem tworząc punkt2 przez wywołanie konstruktora, któremu jako parametry przekazujemy wartości x i y z punkt1:

Punkt punkt1 = new Punkt(10, 10);
 Punkt punkt2 = new Punkt(punkt1.getX(), punkt1.getY());

Rozwiązuje to nasz problem, ponieważ zmienne x i y są typów prostych, a na nich nie obowiązuje już prawo kopiowania referencji. Mając więc kod poniższej postaci:

int zmienna1 = 10;
 int zmienna2 = zmienna1;
 zmienna1 = 30;

po zmienieniu wartości zmienna1 na 30, zmienna2 nadal zachowa wartość 10.

Płytkie kopiowanie obiektów (shallow copy)

W Javie do kopiowania obiektów wykorzystuje się często metodę clone() , ale żeby móc jej używać nasza klasa powinna najpierw implementować interfejs Cloneable , jeśli tego nie zrobimy otrzymamy wyjątek Clone not supported exception.Klonowanie służy do stworzenia kopii naszego obiektu, w swojej podstawowej formie powoduje utworzenie nowej instancji obiektu tego samego typu co obiekt, na rzecz którego metoda została wywołana zachowując wszystkie pola wewnętrzne w takiej samej formie.

Aby pokazać jej działanie musimy zmodyfikować nasze klasy Punkt oraz Odcinek do poniższej formy:

Punkt.java

public class Punkt implements Cloneable {
     private int x;
     private int y;
 
     public Punkt(int x, int y) {
         this.x = x;
         this.y = y;
     }
 
     public int getX() {
         return x;
     }
     public void setX(int x) {
         this.x = x;
     }
     public int getY() {
         return y;
     }
     public void setY(int y) {
         this.y = y;
     }
 
     @Override
     public Object clone() throws CloneNotSupportedException {
         return super.clone();
     }
 }

Odcinek.java

public class Odcinek implements Cloneable {
     private Punkt start;
     private Punkt koniec;
 
     private Odcinek() {}
 
     public Odcinek(Punkt start, Punkt koniec) {
         this.start = start;
         this.koniec = koniec;
     }
 
     public Punkt getStart() {
         return start;
     }
     public void setStart(Punkt start) {
         this.start = start;
     }
     public Punkt getKoniec() {
         return koniec;
     }
     public void setKoniec(Punkt koniec) {
         this.koniec = koniec;
     }
 
     @Override
     public Object clone() throws CloneNotSupportedException {
         return super.clone();
     }
 }

Jak widać w obu przypadkach została dodana informacja o implementowaniu interfejsu Cloneable oraz przesłoniliśmy metodę clone().

Problemem metody clone() jest fakt, że domyślnie ma ona zasięg pakietowy (protected), co w niektórych sytuacjach czyni ją bezużyteczną. Warto przesłonić ją więc jako metodę publiczną i dać do niej dostęp z każdego miejsca aplikacji.

Delikatnie modyfikując Przyklad1 otrzymujemy poniższy kod:

public class Przyklad2 {
     public static void main(String[] args) throws CloneNotSupportedException {
         Punkt punkt1 = new Punkt(10, 10);
         Punkt punkt2 = (Punkt) punkt1.clone();
         Punkt punkt3 = new Punkt(10, 10);
         boolean porownanie1 = punkt1==punkt2;
         boolean porownanie2 = punkt1==punkt3;
 
         System.out.println("Porownanie1: "+porownanie1);
         System.out.println("Porownanie2: "+porownanie2);
         wyswietl(punkt1, punkt2, punkt3);
 
         punkt2.setX(11);
         punkt3.setX(12);
         wyswietl(punkt1, punkt2, punkt3);
     }
 
     public static void wyswietl(Punkt... punkty) {
         int size = punkty.length;
         for(int i=0; i<size; i++) {
             System.out.println("Punkt "+i+" x:"+punkty[i].getX()+" ; y:"+punkty[i].getY());
         }
         System.out.println();
     }
 }

W 4 linijce widać zmianę polegającą na tym, że nie przypisujemy już do referencji punkt2 tego samego obiektu, ale już jego kopię (musimy ją rzutować na odpowiedni typ, ponieważ metoda clone() zwraca typ Object). Wywołanie programu pokazuje, że osiągamy zamierzony efekt, wszystkie referencje są różne, modyfikacja wewnętrznych pól dowolnego obiektu nie wpływa na pola obiektu, który kopiowaliśmy.

Płytkie kopiowanie obiektów Punkt

Problemy związane z płytkim kopiowaniem obiektów.

Kurs Java

Płytkie kopiowanie obiektów niestety nie chroni nas przed innymi problemami. Tworzy ona co prawda nowy obiekt i kopiuje pola, ale pola kopiuje jedynie na zasadzie przepisania wartości pól typów prostych oraz przypisania tych samych referencji w przypadku pól typów obiektowych. O ile w przypadku naszej klasy Punkt nie występują żadne problemy, ponieważ przechowuje ona jedynie dwa pola typu int (typ prosty), tak w przypadku klasy Odcinek pojawią się już problemy, które pokazano poniżej.

Przyklad3.java

public class Przyklad3 {
     public static void main(String[] args) throws CloneNotSupportedException {
         Punkt p1 = new Punkt(0,0);
         Punkt p2 = new Punkt(10, 10);
 
         Odcinek odcinek1 = new Odcinek(p1, p2);
         Odcinek odcinek2 = (Odcinek) odcinek1.clone();
 
         boolean porownanie = odcinek1 == odcinek2;
         System.out.println("Porownanie: "+porownanie);
         System.out.println("Odcinek1 xStart: "+odcinek1.getStart().getX());
         System.out.println("Odcinek2 xStart: "+odcinek2.getStart().getX());
 
         odcinek2.getStart().setX(20);
         System.out.println("Odcinek1 xStart: "+odcinek1.getStart().getX());
         System.out.println("Odcinek2 xStart: "+odcinek2.getStart().getX());
     }
 }
Płytkie kopiowanie odcinków

Widzimy, że co prawda metoda clone() ponownie poprawnie utworzyła nowy obiekt - w tym przypadku klasy Odcinek, jednak zmiana pola wewnętrznego pola start (które jest typu obiektowego) powoduje analogiczne problemy jak na samym początku lekcji w Przyklad1.java. Niestety używając metody clone() wszystkie wewnętrzne pola są kopiowane w "płytki" sposób, nawet jeśli jakieś pole jest typu obiektowego i posiada przesłoniętą metodę clone() to nie jest ona wywoływana.

Metoda clone w naszej klasie odcinek w rzeczywistości wygląda tak:

public Object clone() throws CloneNotSupportedException {
     Object o = new Odcinek();
     ((Odcinek)o).setStart(this.start);
     ((Odcinek)o).setKoniec(this.koniec);
     return o;
 }

Widzimy więc, że korzystanie z metody clone() sprawia dużo problemów przy kopiowaniu, przejdźmy jednak do jeszcze innego podejścia do tematu.

Głębokie kopiowanie obiektów (deep copy)

Głębokie kopiowanie obiektów pozwala nam uchronić się przed wszystkimi wcześniej wspominanymi problemami poprzez wykonanie kopii wszystkich pól wewnętrznych danego obiektu (nawet jeśli jest ich kilka poziomów). Żeby zrobić to z wykorzystaniem metody clone() musimy zadbać o to, żeby każdy z memberów klas, które mają podlegać kopiowaniu przesłaniał tę metodę (oraz implementował interfejs Cloneable), ale także nie wykorzystywał wyłącznie wywołania domyślnej metody clone() klasy Object, ale kopiował każde pole osobno.

Wyjątkiem od reguły są klasy, które zawierają wyłącznie pola typów prostych - w ich przypadku wywołanie metody clone klasy Object w zupełności wystarczy.

W naszym przykładzie klasa Punkt zawiera wyłącznie dwa pola typu int - ostatnia jej implementacja więc nas zadowala. Klasa odcinek zawiera jednak dwa pola typu obiektowego Punkt - musimy je więc sklonować ręcznie.

Poprawna metoda clone klasy Odcinek (reszta klasy pozostaje bez zmian):

@Override
     public Object clone() throws CloneNotSupportedException {
         Odcinek odcinek = new Odcinek();
         Punkt pStart = (Punkt) this.getStart().clone();
         Punkt pKoniec =  (Punkt) this.getKoniec().clone();
         odcinek.setStart(pStart);
         odcinek.setKoniec(pKoniec);
 
         return odcinek;
     }

w 4 i 5 linii powyższego kodu tworzymy kopię pól start i koniec dzięki czemu w wynikowym obiekcie metody clone() otrzymamy zupełnie niezależny obiekt klasy Odcinek. Zmiana jego pól start i koniec nie wpłynie na pola obiektu, który kopiujemy.

Wywołując ponownie Przyklad3.java widzimy, że wynik jest taki jakiego oczekiwaliśmy - zmiana współrzędnej x pola start obiektu odcinek2 nie wpłynęła na analogiczne pole w obiekcie odcinek1.

Głębokie kopiowanie obiektu typu Odcinek

Oczywiście należy pamiętać, że jeśli klasa Punkt przechowywała by inne typy obiektowe, a nie tylko pola typów prostych to również w tej klasie musielibyśmy zapewnić podobne działanie metody clone() jak w powyższym z klasy Odcinek.

Kurs Java

Podsumowanie

Oczywiście może zdarzyć się sytuacja, w której głębokie kopiowanie obiektów wcale nie będzie nam potrzebne(co raczej rzadko ma miejsce) i w takiej sytuacji nie będzie nam potrzebna samodzielna implementacja metody clone(). W ogólności wykorzystanie metody clone(), nawet poprawnie napisanej, nie jest uważana za dobrą praktykę o czym pisze nawet Joshua Bloch w swojej książce o efektywnym programowaniu. Jest to spowodowane tym, że wystarczy przeoczyć choćby jedno źle skopiowane pole i nasza klasa staje się źródłem ciężkich do wykrycia błędów. Może to być szczególnie mylące, jeśli korzystamy z czyjejś biblioteki, lub sami taką stworzymy i nie dostarczymy w dokumentacji informacji na temat tego, czy nasza metoda clone() zapewnia płytkie, czy głębokie kopiowanie (lub jeśli teoretycznie zawiera głębokie kopiowanie, a w rzeczywistości posiada wspomniany błąd).

Istnieją lepsze rozwiązania tego problemu takie jak wykorzystanie konstruktora kopiującego, zewnętrznych bibliotek do klonowania, czy bardziej egzotycznych sztuczek jak refleksja - o tym dowiesz się w kolejnej lekcji.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.

Poniżej znajdziesz archiwalne wpisy z czasów, gdy strona była jeszcze hobbystycznym blogiem.

walden

W ostatnim przykładzie należy dodać konstruktor domyślny do klasy Odcinek, np. taki: <code> public Odcinek() { this(new Punkt(0, 0), new Punkt(0, 0)); } </code>