Jaki styl powinien mieć Czysty Kod?
Czysty Kod nie zawsze jest obiektowy. Czasem będzie napisany w stylu proceduralnym. A jaki styl jest lepszy : proceduralny czy obiektowy? Taki który w danych warunkach ułatwia nam jego rozwój i czytelność – zgodnie z zasadami Czystego Kodu.
Poniżej przykład kodu proceduralnego który posłuży mi do rozważań o czystości kodu jak i jego refaktoryzacji do kodu obiektowego.
public class Rectangle {
double width;
double height;
}
...
public class Geometry {
double area(Object shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.radius * circle.radius
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.width * rectangle.height;
} else if (shape instanceof Square) {
Square square = (Square) shape;
return square.size * square.size;
}
throw new IllegalArgumentException("Unknown shape");
}
}
Wyboru stylu w jakim będzie dalej pisany kod dokonuję na podstawie obserwacji kierunku zmian, które wynikają z pojawiających się nowych wymagań biznesowych.
Jakie zmiany umożliwia kod proceduralny?
Jeżeli głównie będę dodawał nowe funkcje operujące na już istniejących strukturach danych, to kod proceduralny (nowe procedury) pozostanie prawdopodobnie nadal czytelny. Przykładem jest nowa funkcja zwracająca najmniejszy prostokąt zawierający figurę.
public class Geometry {
Rectange containingRectange(Object shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
Rectangle rectangle = new Rectangle();
rectangle.width = 2 * circle.radius;
rectangle.height= 2 * circle.radius;
return rectangle;
} else if (shape instanceof Rectangle) {
return (Rectangle) shape;
} else if (shape instanceof Square) {
...
}
throw new IllegalArgumentException("Unknown shape");
}
}
Kiedy kod proceduralny stanie się nieczytelny?
Ale jeżeli planuje dodać lub zmodyfikować istniejące struktury danych to wymusi to zmiany we wszystkich istniejących procedurach. Co się stanie kiedy zdecyduję się na zmianę komponentów w strukturze danych Rectangle na punkty opisujące 2 przeciwległe rogi kwadratu?
public class Point {
double x,y;
}
public class Rectangle {
Point topLeft;
Point bottomRight;
}
Nie trudno zauważyć, że taka zmiana wymusi wiele zmian w istniejących procedurach. Sposobem na uniknięcie wielu zmian (lub ich minimalizacje) jest umieszczenie z strukturze Rectangle metod getWidth() oraz getHeigth() które dokonają niezbędnych wyliczeń.
public class Rectangle {
private Point topLeft;
private Point bottomRight;
double getWidth(){
return Math.abs(topLeft.x - bottomRight.x);
}
double getHeight(){
return Math.abs(topLeft.y - bottomRight.y);
}
}
Ale zauważ, że od tego momentu zaczynam ukrywać szczegóły struktury danych. Szczegóły w klasie Rectangle zostały ukryte a nowe metody wyliczają potrzebne dane. W ten sposób zaczynam zmieniać styl kodu z proceduralnego na obiektowy.
Jak refaktoryzować kod proceduralny na obiektowy?
Dokonaj samo-enkapsulacji danych w strukturach
Na początku dodaję konstuktory i dokonuję enkapsulacji pół we wszystkich strukturach danych. W moim przypadku dane w strukturach nie są zmieniane, więc pola mogą być typu final.
public class Circle {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
public double getRadius() {
return radius;
}
}
Zdefiniuj wspólny interfejs / klasę bazową dla obecnych struktur
Następnie definiuję pustą klasę bazową Shape którą będą rozszerzać wszystkie struktury danych. Procedura “area” zamiast obiektu “Object” akceptuje od tej pory jako parameter tylko rozszerzenia klasy abstrakcyjnej “Shape”. Może to być także alternatywnie wspólny interfejs.
public abstract class Shape{
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
public double getRadius() {
return radius;
}
}
...
Przenieś logikę z procedury do klasy bazowej
W celu przeniesienia logiki do klasy bazowej wykonam niewielką modyfikację, aby następnie móc skorzystać z przeniesienia metody w narzędziu IntelliJ.
public class Geometry {
static double area(Shape shape) {
return new Geometry().calculateArea(shape);
}
private double calculateArea(Shape shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
return Math.PI * circle.getRadius() * circle.getRadius();
} else if (shape instanceof Rectangle) {
Rectangle rectangle = (Rectangle) shape;
return rectangle.getWidth() * rectangle.getHeight();
} else if (shape instanceof Square) {
Square square = (Square) shape;
return square.getSize() * square.getSize();
}
throw new IllegalArgumentException("Unknown shape :" + shape.getClass());
}
}
Powyższy kod uzyskałem poprzez ekstrakcję nowej metody, następnie usunięcie słowa “static” i dodanie wywołania kontruktora.
Następnie przenoszę metodę zawierającą logikę “calculateArea” z “Geometry” do klasy bazowej “Shape”.
public class Geometry {
static double area(Shape shape) {
return shape.calculateArea();
}
}
public abstract class Shape {
double calculateArea() {
if (this instanceof Circle) {
Circle circle = (Circle) this;
return Math.PI * circle.getRadius() * circle.getRadius();
} else if (this instanceof Rectangle) {
Rectangle rectangle = (Rectangle) this;
return rectangle.getWidth() * rectangle.getHeight();
} else if (this instanceof Square) {
Square square = (Square) this;
return square.getSize() * square.getSize();
}
throw new IllegalArgumentException("Unknown shape :" + getClass());
}
}
Po tej refaktoryzacji pojawił się zapach kodu : “klasy bazowe zależne od swoich klas pochodnych”. Rozwiązanie problemu poprowadzi nas do kolejnego przekształcenia.
Wykonaj przesunięcie metody w dół do klas pochodnych
Przekształcenie jest w pełni zautomatyzowane w wielu środowiskach jak IntelliJ, Eclipse, NetBeans.
Usuń niepotrzebną logikę w klasach pochodnych
Ostatecznie kończymy przekaształceniem “zastąp wyrażenia warunkowe polimorfizmem”. W każdej z podklas (czyli naszych dawnych struktur anych) tyko jeden warunek będzie prawdziwy. Poniżej refaktortyzacja metody “calculateArea” w klasie Circle.
Poniżej wynik końcowy naszej refaktoryzacji
public class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
double calculateArea() {
return Math.PI * circle.radius * circle.radius;
}
}
public class Square extends Shape {
private double size;
public Square(double size) {
this.size = size;
}
@Override
double calculateArea() {
return size * size;
}
}
public class Geometry {
static double area(Shape shape) {
return shape.calculateArea();
}
}
Dodatkowo możemy jeszcze wykonać “wchłonięcie funkcji “Geometry.area” a następnie zmienić nazwę metody “calculateArea” na “area” aby powrócić od dawnego nazewnictwa.
Polecam także artykuł Jak czyścić kod według koncepcji piramidy refaktoryzacji? gdzie opisuję refaktoryzację do wzorca Interpreter.