2012-12-26 18:53
Admit it: all your projects have this one slightly odd part. It has many different names: “helper” classes, “common” functions, “auxiliary” code (if you’re that erudite), “shared” subroutines… Or simply a utility module, which is how I will call it from here.
This is the logic outside of application’s core. The non-essential building blocks which are nevertheless used throughout the whole project. Heap of scraps, squirreled to plug in the holes of your language, framework or libraries.
Those flea markets are notoriously difficult to keep in check. If left to their own devices, they gradually expand, swallowing more and more of incoming logic – code that would otherwise go into more specific, adequate places. This is where you can carefully observe the second law of programming dynamics: without directed influence, entropy can never decrease.
You cannot do away with utility modules, though. There will always be code that exhibits their two characteristic properties:
- frequent usage throughout most of the project’s codebase
- loose coupling with the rest of the project
But neither is binary: they are more like a continuous spectrum. And to make it even more difficult, they are also constantly in flux, effected by any change that happens in the code. To remain minimal (and thus manageable), utility modules require probably the most frequent, extensive and aggressive refactoring.
So how exactly do you deal with this necessary menace? I think it’s a matter of reacting quickly and decisively when one of these properties change. For the most part, the usage and (inter)dependencies of your utility package will dictate which one of these four steps you should take:
- When certain entity (function, class) grows universal, up to a point when it’s used by more than one subpackage inside your app, it is usually time to put it into the utility module.
Although somewhat obvious, this point is really important for one particular reason: preventing code duplication. Without a designated place for helper/common/etc. code, you will have different implementations of the very same things spread like a vermin across the whole codebase.
And you really don’t want to have four distinct functions for turning a sequence [X, Y, Z]
into a string "X, Y and Z"
, or something similar to that. This is way worse than even the biggest utility module you might end up dealing with.
- Symmetrically, whenever an entity stops being used by more than one part of the program, you may roll it back into that sole part which still requires it.
For most, this will be the trickiest part. I can tell you upfront that you shouldn’t actually do it every time. There are things basic enough that you don’t need at all to track usage of: the “join with ‘and'” example might be one such instance.
But there are times when it’s more reasonable to put some utility stuff back into more dedicated package, because it simply belongs there. If you need to have that “and-join” localized, for example, you will likely find it more sensible to place its modified version into internationalization package. On the other hand, if you learn that it’s used only to format log messages which are never seen by the user, you can put it directly into logging module or just scrap altogether. (After all, programmers are not easily offended by poorly punctuated sentences).
- When utility code evolves into subpackage on its own, extract it as such.
When your internationalization needs require not only a localized “and-join” but also a way to match numeric values with noun plurals ("1 apple"
vs "3 apples"
; my native language is especially complex here), this can be the reason for creating a full-blown i18n package.
It is quite natural process, for simple but useful snippets – ones that typically land in shared module – to grow more robust, functional and thus complex. At certain size threshold, it’s reasonable to promote them into next structural level: module or package.
- Loosening up already loose ties may call for creating an external library.
You probably don’t write code with the express intent of reusing it across different projects, regardless of whether you were convinced by Mythical Man Month that it costs three times as much to do so. But “accidents” happen, and you can sometimes find a non-trivial piece of utility code that appears to be floating, that doesn’t depend on anything else inside the parent project.
Should you make such a discovery, by all means make it into a library. It doesn’t have to be real, external library – much less an open source one. Releasing a project into the wild is serious and time-consuming task, so I won’t ask you to do it every time (even though the world would benefit greatly if you did).
Treat it as third-party code, though. Separate it into different root package, add a new build target for it, include it as a .jar or .egg rather than .java or .py files – and so on. This is a small investment that makes it much more likely for the code to increase its value threefold.
Reducing the size of that pesky utility module will be just an added bonus.
So help you helper classes and utilize your utility modules for shared, common good :)
2011-03-05 19:48
Często powtarzanym bon motem jest twierdzenie, iż przedwczesna optymalizacja (preemptive optimization) jest źródłem wszelkiego zła. Dużo w tym racji i równie dużo przesady. Jednak na pewno faktem jest, że pisanie wydajnych i efektywnych, czyli zoptymalizowanych (“przedwcześnie” lub poniewczasie) programów nie jest prostym zadaniem.
W celu łatwiejszej identyfikacji obszarów, w których można zastosować polepszające wydajność poprawki, możliwe rodzaje optymalizacji zwykle dzieli się na kilka rodzajów. Niektóre z nich są aplikowane przez kompilator i pozostają w dużym stopniu poza kontrolą programisty, który może co najwyżej unikać sytuacji blokujących możliwość ich zastosowania – jeśli w ogóle o nich wie. Pozostałe można pewnie zaklasyfikować na kilka sposobów, a podziałem stosowanym przeze mnie jest następujący:
- Optymalizacje projektowe, odnoszące się do wewnętrznej struktury całego projektu i polegające na bardziej efektywnym zorganizowaniu jego modułów, funkcji i klas. Chociaż nie jest to regułą, często oznacza to oddalenie się od uczenie nazywanej “dziedziny problemu” i przybliżenie się do szczegółów technicznych konkretnej platformy.
- Optymalizacje zbliżone do refaktoringu kodu, niezmieniające drastycznie jego architektury, ale modyfikujące kluczowe aspekty implementacji wpływające na wydajność. Prawie zawsze są one związane ze specyficznymi cechami używanego języka lub środowiska.
- Optymalizacje algorytmów, czyli modyfikacje ogólnych przepisów pod kątem konkretnych danych lub zastosowań albo całkowite wymiany jednych algorytmów na inne. Jak nietrudno się domyślić, takie zmiany są raczej niezależne od szczegółów technicznych implementacji, przynajmniej teoretycznie ;)
- Optymalizacje instrukcji, znane też jako mikrooptymalizacje, to po prostu inny sposób wykonywania elementarnych operacji. Być może najbardziej znanym rodzajem takiej poprawki jest używanie przesunięcia bitowego do obliczania potęg liczb całkowitych dla podstawy 2.
Kolejność na powyższej liście odpowiada głównie zakresowi, w jaki optymalizacje wpływają na kształt programu i jego kod. Dość powszechna jest aczkolwiek opinia, że zmiany o bardziej dalekosiężnych skutkach dają też wyraźniejsze efekty, co generalnie nie musi być prawdą. Wystarczy przypomnieć sobie znaną zasadę Pareto, która w tym przypadku oznacza, że 10/20% jest wykonywana przez 90/80% czasu, więc nawet drastyczne optymalizacje zastosowane wobec pozostałej jego części dadzą raczej nikły efekt.
Optymalizację dodatkowo komplikuje wiele innych spraw, jak choćby to, że:
- Niskopoziomowe optymalizacje instrukcji są w zasadzie niepraktyczne w wielu współczesnych językach. Im więcej warstw abstrakcji w rodzaju maszyn wirtualnych oddziela kod źródłowy od instrukcji procesora, tym trudniej określić, jak dana modyfikacja rzeczywiście wpłynie na wydajność i czy nie będzie to przypadkiem wpływ negatywny.
- Optymalizacje projektowe niemal z założenia są “przedwczesne” i wymagają rozważenia jeszcze przed napisaniem kodu programu – lub napisaniem go od nowa. Wymagają więc one doświadczenia i umiejętności przewidywania ich skutków – krótko mówiąc, zdolności prawie profetycznych ;)
- Stosowanie algorytmicznych optymalizacji jest z kolei obciążone ryzykiem potknięcia się o praktyczny aspekt teoretycznych miar takich jak złożoność obliczeniowa. Pamiętać trzeba choćby o tym, że notacja dużego O zakłada rozmiar danych rosnący do nieskończoności i nieważność stałych czynników, co w praktyce nie musi być rozsądnymi założeniami. Dodatkowo niespecjalnie pomaga tu fakt, że współczesne aplikacje są raczej asynchroniczne i interaktywne, więc nie zawierają znowu aż tak wiele algorytmów w klasycznym, proceduralnym, synchronicznym rozumieniu.
Po uwzględnieniu tych uwag wychodzi na to, że najbezpieczniejszym rodzajem optymalizacji jest ten, który polega na odpowiednim refaktoringu kodu – zwłaszcza, jeśli jest on uzasadniony przeprowadzonym uprzednio profilowaniem. Może się bowiem okazać, że najbanalniejsza zmiana, jak np. wydzielenie globalnego obiektu tworzonego w często wywoływanej funkcji, daje natychmiastowy i zauważalny efekt. A jest to efektem jedynie tego, iż optymalizacja była po prostu zastosowana we właściwym miejscu.
I na pewno nie była “przedwczesna” ;P