Helion
E
Tytuł oryginału: The Practice of Programming Tłumaczenie: Łukasz Piwko Projekt okładki: Maciej Pasek ISBN:
978-83-246-3226-8
Authorized translation from the English language edition, entitled: The Practice of Programming, ISBN 020161586X, by Brian W. Kernighan and Rob Pike, published by Pearson Education, Inc, publishing as Addison Wesley. Copyright© 1999 by Lucent Technologies. Polish language edition published by Helion S.A. Copyright©
201 1 .
All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education Inc. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki le, 44-100 GLIWICE tel.
32 231 22 19, 32 230 98 63
e-mail:
[email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie ?prapro Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Pliki z przykładami omawianymi w książce można znaleźć pod adresem:
ftp:I/ftp.helion.pl/przyklady/prapro.zip Printed in Poland.
Spis treści
Wstęp
7
1. Styl 1.1. Nazwy 1 .2. Wyrażenia i instrukcje 1 .3. Spójność i idiomy 1 .4. Makra w roli funkcji 1.5. Liczby magiczne 1 .6. Komentarze 1 .7. Dlaczego warto dbać o styl?
11 13 16 20 28 29 33 38
2. Algorytmy i struktury danych 2.1. Przeszukiwanie 2.2. Sortowanie 2.3. Biblioteki 2.4. Sortowanie szybkie w Javie 2.5. Notacja O 2.6. Tablice rozszerzalne 2.7. Listy 2.8. Drzewa 2.9. Tablice mieszania 2.10. Podsumowanie
39 40 42
3. Projektowanie i implementacja 3.1. Algorytm łańcucha Markowa 3.2. Wybór struktury danych 3.3. Budowa struktury danych w języku C 3.4. Generowanie tekstu 3.5. Java 3.6. c++ 3.7. Awk i Perl 3.8. Wydajność 3.9. Wnioski
69 70 72 73 77 79 83 86 88 89
44
47 SO
51 54 59 64 68
4
SPIS TREŚCI
4. Inteńejsy 4.1. Wartości oddzielane przecinkami 4.2. Prototyp biblioteki 4.3. Biblioteka dla innych 4.4. Implementacja w języku C++ 4.5. Zasady projektowania interfejsów 4.6. Zarządzanie zasobami 4.7. Obsługa błędów 4.8. Interfejsy użytkownika
93 94 95 99 108 112 114 117 12 1
5. Usuwanie błędów 5.1. Programy diagnostyczne 5.2. Dobre pomysły, łatwe błędy 5.3. Brak pomysłów, trudne błędy 5.4. Ostatnia deska ratunku 5.5. Błędy niepowtarzalne 5.6. Narzędzia diagnostyczne 5. 7. Błędy popełnione przez innych 5.8. Podsumowanie
125 126 127 131 135 138 140 143 144
6. Testowanie 6.1. Testuj kod podczas jego pisania 6.2. Systematyczne testowanie 6.3. Automatyzacja testów 6.4. Ramy testowe 6.5. Testowanie przeciążeniowe 6.6. Porady dotyczące testowania 6.7. Kto zajmuje się testowaniem 6.8. Testowanie programu markov 6.9. Podsumowanie
147 148 153 157 1 59 163 1 66 167 168 170
7. Wydajność 7.1. Wąskie gardło 7.2. Mierzenie czasu wykonywania i profilowanie programu 7.3. Strategie przyspieszania 7.4. Regulowanie kodu 7.5. Oszczędzanie pamięci 7.6. Szacowanie 7.7. Podsumowanie
171 172 177 181 1 84 188 191 193
8. Przenośność 8.1. Język 8.2. Nagłówki i biblioteki 8.3. Organizacja programu 8.4. Izolacja 8.5. Wymiana danych 8.6. Kolejność bajtów 8.7. Przenośność a uaktualnianie 8.8. Internacjonalizacja 8.9. Podsumowanie
195 196 202 204 208 209 210 213 215 218
SPIS TREŚCI
9. Notacja 9. 1 . Formatowanie danych 9.2. Wyrażenia regularne 9.3. Programowalne narzędzia 9.4. Interpretery, kompilatory i maszyny wirtualne
9.5. Programy, które piszą programy 9.6. Generowanie kodu za pomocą makr 9.7. Kompilacja w locie
5 221 222 228 234 237 242 246 247
A Epilog
253
B Zebrane zasady
255
Skorowidz
259
6
SPIS TREŚCI
Wstęp
Czy zdarzyło Ci się kiedykolwiek .. . zmarnować dużo czasu na pisanie niewłaściwego algorytmu? użyć zbyt skomplikowanej struktury danych? pominąć oczywisty błąd w testowanym programie? spędzić cały dzień na szukaniu takiego błędu? przerabiać program, aby działał trzy razy szybciej i zużywał mniej pamięci? przenosić program ze stacji roboczej na komputer PC albo odwrotnie? próbować wprowadzić sensowne zmiany w programie napisanym przez kogoś innego? przepisać program od nowa, bo nie dało się go zrozumieć? Fajnie było? Podobne rzeczy zdarzają się programistom nieustannie, ale nie zawsze można sobie z nimi łatwo poradzić. Główną przyczyną tego problemu jest to, że takie zagadnienia, jak testowanie, diagnostyka, przenośność, wydajność, alternatywy projektowe i styl
-
praktyka
programowa
nia - są często na zajęciach z informatyki i programowania traktowane po macoszemu. Więk szość programistów uczy się tego wszystkiego przypadkiem w miarę zdobywania doświadczenia, ale są też tacy, którym zagadnienia te są całkiem obce. W świecie rządzonym przez ogromne i skomplikowane interfejsy, ciągle zmieniające się narzędzia, języki i systemy, w którym wszyscy wciąż żądają więcej wszystkiego, można łatwo zapomnieć o podstawowych zasadach stanowiących kamień węgielny dobrego oprogramowania - prostocie, przejrzystości i ogólności. Nietrudno też nie docenić rozmaitych narzędzi i nota cji pozwalających zmechanizować proces powstawania oprogramowania, a więc i zaprzęgnąć komputery do programowania samych siebie. Centralnym tematem książki są te trzy wzajemnie ze sobą powiązane zasady, które mają za stosowanie we wszystkich przypadkach korzystania z komputera. Powtórzymy je zatem jeszcze raz. Zachowanie prostoty pozwala na uzyskanie krótkiego i łatwego w obsłudze kodu. Przej rzystość oznacza, że kod jest łatwy do zrozumienia zarówno dla ludzi, jak i maszyn. Kod ogólny to taki kod, który dobrze działa w różnych sytuacjach i szybko adaptuje się do nowych warun ków. Natomiast
automatyzacja to
sztuka zmuszania maszyny do wykonywania pracy za nas, co
pozwala nam uwolnić się od wielu żmudnych zadań. Analizując techniki programowania
8
WSTĘP
w różnych językach, od algorytmów i struktur danych, poprzez projektowanie, diagnostykę i testo wanie do optymalizacji wydajności, możemy wyodrębnić pewne uniwersalne koncepcje progra mistyczne, które są całkowicie niezależne od jakiegokolwiek języka, systemu operacyjnego czy paradygmatu programistycznego. Książka ta jest owocem wieloletniego doświadczenia w pisaniu i obsłudze licznych pro gramów, prowadzenia zajęć z programowania oraz współpracy z szerokim gronem programi stów. Chcemy podzielić się naszą praktyczną wiedzą, dać Ci możliwość skorzystania z naszego doświadczenia oraz wskazać zarówno bardziej, jak i mniej doświadczonym programistom, jak biegle opanować sztukę produktywnego pisania programów. Książka jest przeznaczona dla różnych grup czytelników. Dla studentów po kursach pro gramowania, chcących zwiększyć swoje umiejętności, ponieważ znajdą tu opis zagadnień, na które w szkole zabrakło czasu. Dla osób piszących programy w pracy, ale dla których nie jest to główne zajęcie, gdyż będą mogły zwiększyć efektywność swojej pracy. Dla zawodowych pro gramistów, którzy odczuwają braki w wiedzy na ten temat albo chcieliby odświeżyć wiadomości, a także dla kierowników projektów programistycznych chcących właściwie kierować swoimi zespołami. Mamy nadzieję, że zdobyte dzięki tej książce wiadomości pomogą Ci pisać lepsze progra my. Jedynym warunkiem wstępnym do jej przeczytania jest trochę doświadczenia programi stycznego, najlepiej w językach C, C+ + lub Java. Oczywiście im większe doświadczenie, tym łatwiej przyswoisz sobie tę wiedzę. Nie da się przejść od poziomu początkującego do profesjo nalisty w ciągu miesiąca. Niektóre z prezentowanych przykładów będą lepiej przemawiać do programistów pracujących w systemach Unix i Linux niż tych, którzy używają Windowsa lub systemu Mac OS, ale ogólnie rzecz biorąc, każdy powinien tu znaleźć coś dla siebie. Publikacja jest podzielona na dziewięć rozdziałów, z których każdy został poświęcony osob nemu aspektowi praktyki programowania. Rozdział 1. traktuje o stylu programowania. Zdecydowaliśmy się omówić ten temat już na samym początku, gdyż jest on niezwykle ważny. Dobrze napisany program jest zawsze lepszy od źle napisanego - ma mniej błędów i łatwiej się go modyfikuje i diagnozuje - dlatego od samego początku należy stosować dobry styl. Ponadto wprowadzamy pojęcie idiomów, które można znaleźć w praktycznie każdym języku. Algorytmy i struktury danych, które będą tematem rozdziału
2., to podstawowy przedmiot
w programie nauczania informatyki i jeden z najważniejszych w kursach programowania. Jako że wiedza ta nie jest obca większości czytelników, ograniczymy się tylko do krótkiego przypo mnienia kilku algorytmów i struktur danych, które można znaleźć w prawie każdym progra mie. Na ich bazie powstają później bardziej złożone algorytmy i struktury, dlatego opanowanie podstaw jest tak bardzo ważne. W rozdziale
3.
przedstawimy analizę projektu i implementacji niewielkiego programu, aby
pokazać zagadnienia związane z algorytmami i strukturami danych w realistycznym ujęciu. Program zaimplementujemy w pięciu językach, aby mieć możliwość porównania w nich pracy ze strukturami danych oraz zobaczenia, jak różnią się one między sobą pod względem ekspre sywności i wydajności. Fundamentalne znaczenie w programowaniu mają interfejsy łączące użytkowników, pro gramy i części programów, i dlatego właśnie sukces oprogramowania w dużej mierze zależy od ich jakości. W rozdziale
4.
pokażemy, jak rozwijała się niewielka biblioteka do przetwarzania
często używanego formatu danych. Przykład ten, mimo iż jest krótki, posłuży nam do zilu strowania wielu zagadnień związanych z projektowaniem interfejsów - abstrakcji, ukrywania informacji, zarządzania zasobami oraz obsługi błędów.
WSTĘP
9
Choć byśmy nie wiadomo jak się starali od samego początku, nie unikniemy błędów, a więc i konieczności przeprowadzenia diagnostyki kodu. W rozdziale 5. opisujemy techniki systema tycznego i skutecznego wykrywania błędów. M iędzy innymi poruszymy temat najczęściej wy stępuj ących błędów oraz omówimy metodykę polegającą na wykorzystaniu faktu, że w danych zwracanych przez narzędzia diagnostyczne można wyodrębnić pewne wzorce pomagające zna leźć przyczynę problemów. Testowanie to zestaw czynności maj ących na celu zapewnienie, na ile się da, że program działa poprawnie i będzie tak działał w toku rozwoj u. W rozdziale 6. kładziemy nacisk na ko nieczność systematycznego testowania oprogramowania zarówno własnoręcznie, jak i maszynowo. Testy wartości brzegowych pozwalaj ą wykryć słabe punkty programu. A utomatyzacj a i platformy testowe ułatwiają przeprowadzanie wyczerpuj ących testów przy względnie małym wysiłku. Te sty obciążeniowe umożliwiają natomiast przeprowadzenie diagnostyki w innym niż typowy zakresie oraz wykrycie całkiem innego rodzaju błędów. Dzięki szybkości dzisiej szych komputerów i wysokiej jakości kompilatorów większość pro gramów od razu po napisaniu działa na tyle szybko, że nie trzeba nic poprawiać . Zdarzają się też jednak takie, które mimo tego działaj ą za wolno lub zajmują zbyt dużo pamięci, albo j edno i drugie. W rozdziale 7. prezentujemy systematyczną metodę optymalizacji wykorzystania za sobów, pozwalaj ącą zachować poprawność i niezawodność kodu. W rozdziale 8. opisujemy zagadnienie przenośności programów. Dobre programy pozostaj ą w uŻytku na tyle długo, że może się zmienić środowisko ich uŻytkowania albo ktoś zechce je przenieść do nowego systemu, na nowy sprzęt albo dostosować do użytku w innej wersji języ kowej. Zapewnianie programom przenośności ma na celu redukcję liczby czynności, których wykonanie jest konieczne, aby dostosować je do działania w potencjalnych nowych warunkach. Liczba języków programowania jest bardzo duża. M ożna wśród nich znaleźć zarówno języ ki ogólnego przeznaczenia, przy użyciu których pisze się większość programów, j ak i j ęzyki specjalistyczne, których zastosowanie ogranicza się do wąskich dziedzin. W rozdziale 9. poka zujemy, jak ważna w programowaniu jest odpowiednia notacja. Jeśli jest dobrze dobrana, po zwala uprościć kodźródłowy, ułatwia implementację, a nawet może nam pomóc utworzyć pro gramy piszące inne programy. A by móc mówić o programowaniu, trzeba pokazać dużo przykładów kodu. Większość z nich została napisana specjalnie na potrzeby tej książki, ale jest też kilka takich, które przeję liśmy z innych źródeł. Dołożyliśmy wszelkich starań, aby ten kod był dobrze napisany i prze testowaliśmy go w kilku systemach bezpośrednio w postaci tekstu maszynowego. Większość programów napisano w j ęzyku C, kilka w C+ + i wJavie, a nieliczne także w ję zykach skryptowych. N a najniższym poziomie języki C i C++ prawie niczym się nie różnią, dzięki czemu wszystkie nasze programy w C można również skompilować w kompilatorze języ ka C+ +. C+ + iJava wywodzą się w prostej linii od języka C. M ają bardzo podobną do swoj e go przodka ekspresywną składnię i wydajność, ale są bogatsze w typy danych i biblioteki. Te trzy języki i wiele innych to dla nas chleb powszedni w codziennej pracy. Wybór języka programowania zależy od problemu, który chcemy rozwiązać, np. systemy operacyjne najlepiej pisać przy użyciu wydaj nych i nieograniczających języków, takich j ak C i C++. Do szybkiego tworzenia prototypów naj lepiej używać interpretera poleceń i j ęzyków skryptowych, takich jak A wk i Perl. Jeśli chodzi o interfejsy użytkownika, prym wiodą Visual Basic i Tcl/Tk, a także Java. Wybór języków do implementacji przykładów ma też podłoże pedagogiczne. Podobnie jak nie każdy problem da się rozwiązać równie dobrze przy użyciu każdego języka, tak nie każdy język jest idealny do naj lepszego przedstawienia każdego problemu. Języki wysokiego poziomu zdejmuj ą z programisty obowiązek podejmowania niektórych decyzji proj ektowych. Jeśli na tomiast użyj emy j ęzyka niskopoziomowego, musimy niekiedy wybrać jedną z kilku możliwości. Dzięki uwidocznieniu większej liczby szczegółów możemy j e lepiej omówić . Z doświadczenia
10
WSTĘP
wiemy, że nawet wówczas, gdy używamy wysokopoziomowych elementów języka, często po maga nam wiedza o tym, jak się one łączą z elementami niskopoziomowymi. Bez tej wiedzy można łatwo nabawić się problemów z wydajnością albo doprowadzić do pozornie dziwnego działania programu. Dlatego w wielu przypadkach, w których normalnie użylibyśmy jakiegoś innego języka, przykłady będziemy prezentować w językuC. M imo to większość materiału w tej książce nie jest związana z jakimkolwiek konkretnym językiem programowania. Strukturę danych wybiera się taką, na jaką pozwala używany język. W jednym języku programowania do wyboru może być wiele takich struktur, a w innym znacznie mniej, ale ogólne zasady dokonywania wyboru zawsze są takie same. Techniki testo wania i wykrywania błędów mogą być odmienne w różnych językach, ale strategia i taktyka ich stosowania pozostają bez zmian. Większość technik optymalizacji wydajności programu można zastosować w każdym języku programowania. Bez względu na to, jakiego języka programowania używasz, Twoim obowiązkiem jest wyko rzystać dostępne narzędzia najlepiej, jak się da. Dobry programista potrafi poradzić sobie z ograni czeniami słabego języka i nieprzyjaznym systemem operacyjnym, natomiast nawet najlepsze środowisko programistyczne nic nie pomoże, jeśli programista ma mierne umiejętności. M amy nadzieję, że książka ta pomoże Ci stać się lepszym programistą i czerpać więcej radości z pro gramowania, niezależnie od tego, jaki poziom umiejętności aktualnie prezentujesz. Jesteśmy bardzo wdzięczni naszym znajomym i kolegom z pracy, którzy przeczytali i sko mentowali pierwsze wersje maszynopisu. Jon Bentley, Russ Cox, John Lakos, John Linder man, Peter M emishian, I an Lance Taylor, Howard Trickey i Chris van Wyk zrobili to wiele razy, zawsze zachowując wyjątkową wnikliwość i skrupulatność . N astępujące osoby zasłużyły z kolei na naszą wdzięczność za cenne uwagi zgłaszane na różnych etapach powstawania tekstu: Tom Cargill, Chris Cleeland, Steve Dewhurst, Eric Grosse, A ndrew Herron, Gerard Holzmann, Doug M cllroy, Paul M cN amee, Peter N elson, Dennis Ritchie, Rich Stevens, Tom Szymanski, Kentaro Toyama, John Wait, Daniel C. Wang, Peter Weinberger, M argaret Wright oraz Cliff Young. Wreszcie A l A ho, Ken A rnold, Chuck Bigelow, Joshua Bl och, Bill Coughran, Bob F landrena, Renee F rench, M ark Kernighan, A ndy Koenig, Sape M ullender, Evi N emeth, M arty Rabinowitz, M ark V. Shaney, Bjarne Stroustrup, Ken Thompson oraz Phil Wadler wspomagali nas dobrymi radami i mądrymi sugestiami. DziękujemyWam wszystkim.
Brian W Kerniglzan RobPike
Styl
Od dawna wiadomo, że najlepsi pisarze często mają za nic zasady retoryki. Zawsze jednak dają czytelnikowi coś w zamian, coś, co wynagrodzi mu to barbarzyństwo. Kto nie ma pewności, że robi to równie dobrze, lepiej jeśli będzie trzymał się tych zasad.
William Strunk i E.B. White, The Elements of Style
Otofr agment kodu z pewnego bardzo starego programu:
(country (country
if
SING) POL) I I
(country = = BRN I ) I (country == ITALY) )
/* * Jeśli kraj to Singapur, Brunei lub Polska, * to aktualny czas jest czasem odpowiedzi, * a nie czasem połączenia. * Zresetuj czas odpowiedzi i ustaw dzie11 tygodnia. */
Kod ten został starannie napisany, sformatowany i opatrzony komentarzem, a program, z którego pochodzi , dzi ała bardzo dobrze. Programi ści, którzy go napi sali, są z niego bardzo dumni i mają do tego powody. A jednak ten fr agment dla przy padkowego czytelnika jest niejasny. Co mają ze sobą wspólnego taki e kraje, jak Singapur, Brunei, Pol ska i Włochy? Czemu w ko mentarzu nie ma nic na temat Włoch? Skoro i stnieje rozbi eżność między kodem a komenta rzem, któryś z nich musi zawierać błąd. Ni ewykluczone, że ma go jeden i drugi . Choci aż bar dzi ej prawdopodobne, że błąd znajduje się w komentarzu, poni eważ kod został przetestowany i dzi ała. N ajprawdopodobniej ktoś zapomniał po zmi enieni u kodu zaktuali zować komentarz. Ni e ma w nim wystarczających informacji na temat tego, co wiąże trzy wymieni one kraje. Gdybyśmy mieli zmodyfikować ten fragment, musielibyśmy uzyskać o nim dodatkowe i nfor macje. Tych kil ka wi erszy reprezentuje bardzo często spotykane zjawisko- całkiem dobrze napi sany kod, który jednak można by było gdzieniegdzi e poprawić .
12
1 . STYL Tematem tej ksi ążki j est praktyka programowani a, czyli pi sani e programów do praktycz
nego użytku. N aszym celem j est pi sani e programów dzi ałaj ących przynaj mni ej tak dobrze, j ak przedstawi ony w przykładzi e, ale pozbawi onych j ego wad i słabych punktów. Będzi emy uczyć si ę od samego początku pi sać j ak naj lepszy kodi doskonali ć go w trakci e j ego rozwoj u. Zaczni emy j ednak w dość ni ezwykły sposób, gdyż na początek omówi my zagadni eni e stylu. Zadani em stylu, który stanowi podstawę dobrego programowani a, j est zapewni eni e łatwości czytani a kodu nami i nnym. Zaj muj emy si ę ni m na samym początku, aby wtrakci e czytani a pozo stałej części ksi ążki Czytelni k był wyczulony na zwi ązane z ni m kwesti e. A by napi sać program , ni e wystarczy tylko zastosowani e poprawnej składni , poprawi eni e błędów i zoptymali zowani e szybkości dzi ałani a. Oprócz komputerów programy czytaj ą także programi ści . Jeśli kod źródłowy j est dobrze napi sany, to czyta si ę go i modyfi kuj e o wi ele ła twi ej ni ż kod napi sany źle. Dyscypli na przy pi sani u wysoki ej j akości kodu zwi ększa prawdo podobi eństwo uni kni ęci a błędów. N a szczęści e utrzymani e tej dyscypli ny j est ni etrudne. Zasady stylu programowani a są wyni ki em zdrowego rozsądku i praktycznych obserwacji , a ni e arbi tralni e przyj ętym zbi orem reguł i przepi sów. Kod źródłowy powi ni en być prosty i przej rzysty, a wi ęc odznaczać si ę ni eskompli kowaną logi ką, naturalną ekspresj ą, konwencj onalnym sposobem użyci a elementów j ęzykowych, dobrze dobranymi nazwami , zgrabnym formatowa ni em i pomocnymi komentarzami , a przede wszystki m ni e powi nno w ni m być żadnych spryt nych sztuczek i ni etypowych konstrukcji . Ważną rolę odgrywa spój ność , poni eważ j eśli wszy scy będą stosować si ę do tych samych reguł, każdemu będzi e łatwi ej zrozumi eć kod napi sany przez kogoś i nnego. O pewnych szczegółach mogą decydować lokalne konwencj e, zarządzeni a ki erowni ctwa albo sam program, ale mi mo to zawsze warto trzymać si ę zbi oru szeroko przyj ę tych konwencji . Będzi emy stosować styl opi sany w ksi ążceJęzyk programowania
C z drobnymi
poprawkami dlaj ęzykówc+ + i Java. Często będzi emy pokazywać obok si ebi e przykład zarówno dobrego, j ak i złego stylu pro gramowani a, gdyż taki e kontrastowe zestawi eni a są bardzo pouczaj ące. Ni e będą to sztuczne twory, a fragmenty realnych programów napi sanych przez zwyczaj nych programi stów( czasami nas) pracuj ących w typowych warunkach stresowych, a wi ęc przy dużej i lości pracy i małej i lo ści czasu. Ni ektóre z ni ch ni eco okroi my dla zachowani a klarowności , ale to ni e oznacza, że będą błędni e zi nterpretowane. Wszystki e źle napi sane ury wki zostaną poprawi one. Poni eważ j ednak kod, o którym będzi e mowa, pochodzi z prawdzi wych proj ektów, może w ni m być wi ele wątpli wych elementów. Gdybyśmy chci eli odni eść si ę do ni ch wszystki ch, musi eli byśmy zbyt ni o odej ść od tematu, dlatego w ni ektórych przykładach określonych j ako dobre wci ąż mogą kryć si ęj aki eś ni eopi sane usterki. A by wyraźni e odróżni ć złe przykłady od dobrych, każdy wi ersz kodu budzącego wątpli wo ści poprzedzi li śmy znaki em zapytani a, j ak w poni ższym fragmenci e programu:
#defi ne ONE 1 #defi ne TEN 10 #defi ne TWENTY 20 Co j est ni e tak z tymi defini cj ami ? Wyobraź sobi e, co trzeba by zmi eni ć , gdybyśmy chci eli tabli cę o aktualnym rozmi arze dwudzi estu elementów (TWENTY) trochę powi ększyć . A by po prawi ć ten kod, powi nni śmy przynaj mni ej zmi eni ć nazwy wszystki ch wartości na nazwy odzwi er ci edlaj ącei ch rolę w programi e:
#defi ne I NPUT-MODE 1 #defi ne I NPUT-BUFS I Z E 10 #defi ne OUTPUT-BUFS I Z E 20
13
1.1. NAZWY
1.1. Nazwy Czym jest nazwa? N azwa fu nkcji lub zmiennej stanowi etykietę obiektu i przekazuje informa cję o jego przeznaczeniu. Powinna dobrze oddawać zastosowanie elementu, być zwięzła oraz dać się łatwo zapamiętać i wymówić , jeśli to możliwe. Wiele informacji można wywnioskować z kontekstu i zakresu dostępności. I m szerszy zakres dostępności zmiennej, tym więcej infor macji powinna przekazy wać jej nazwa.
Stosuj nazwy deskryptywne dla zmiennych globalnych i krótkie nazwy dla zmiennych lo kalnych. Zmienne globalne z defi nicji mogą występować w każdym miejscu programu, a więc ich nazwy powinny być na tyle długie i nasycone informacją, aby czytający kod mógł się zo rientować , do czego służą. Warto też przy deklaracjach takich zmiennych dodawać komentarze:
npend i ng
i nt
; O;
li Aktualna długość kolejki wejściowej
Także fu nkcje, klasy i struktury globalne powinny mieć nazwy deskryptywne, pozwalające odgadnąć ich rolę w programie. N atomiast w przypadku zmiennych lokalnych wystarczające są nazwy krótkie. Jeśli defi niujemy zmienną wewnątrz fu nkcji, to nazwa n może być wystarczająca,
npoi nts
jest w sam
raz, a n umberOf Poi nts to już przesada. N azwy konwencjonalnych zmiennych lokalnych mogą być bardzo krótkie. N a przykład nazwy i ij dla zmiennych pętlowych,
p iq
dla wskaźników oraz s i t dla łańcuchów stosuje się
tak często, iż zamiana ich na dłuższe nie dość , że nie przyniesie pożądanego ef ektu, to jeszcze może mieć wręcz odwrotny skutek. Porównajmy:
for ?
(theEl ementindex ; O ; theEl ement i ndex < numberOfEl ement s ; theEl ement i ndex++) el ementArray [theEl ement i ndex] ; theEl ement i ndex;
z
for ( i e l em [ i ]
O; i;
<
nel ems ;
i ++)
Programistów często zachęca się do stosowania długich nazw, bez względu na kontekst ich użycia. To jest błąd- przejrzystość często uzyskuje się poprzez zwięzłość . I stnieje wiele konwencji nazewniczych i lokalnych zwyczajów. M iędzy innymi wskaźni kom nadaje się nazwy zaczynające się od litery p lub się na nią kończące, np. zmiennych G l obal nych zaczyna się od wielkiej litery, a nazwy
STAŁYCH
nodep;
nazwy
pisze się w całości
wielkimi literami. N iektórzy stosują jeszcze bardziej wyszukane zasady nadawania nazw i ko dują w nich np. informacje o typie i sposobie użycia zmiennej. Wówczas nazwapch może ozna czać wskaźnik na znak, a nazwy strTo i strFrom - łańcuchy do zapisu i odczytu. Jeśli chodzi o zasady typografi czne, tj. czy pisać
npend i ng, n umPendi ng
czy num_pendi ng, to jest to sprawa
gustu. Ważne jest nie to, jaką konkretną konwencję zastosujemy, lecz to, aby wybrać jedną i stosować ją zawsze. Spójne konwencje nazewnicze pomagają w czytaniu nie tylko własnego kodu, lecz także kodu napisanego przez innych programistów. Ponadto ułatwiają wymyślanie nowych nazw w trakcie pisania kodu. I m program dłuższy, tym większe znaczenie ma systematyczny wybór dobrych i deskryptywnych nazw.
14
1 . STYL
w językach c++ i Java stosowanie klarownych i deskryptywnych nazw o odpowiedniej długości ułatwiają techniki zarządzania zakresem dostępności zmiennych w postaci pakietów Gava) i przestrzeni nazw(C+ +).
Bądź konsekwentny. Jeśli elementy są
ze sobą powiązane, nadaj im nazwy, które to powiąza
nie odzwierciedl ają, a przy okazji zaznacz to, co je różni. Oprócz tego, że są zbyt długie, nazwy składowych w poniższej klasie wJavie są kompletnie niespójne:
?
cl ass UserQueue C i nt noOfiterns i nQ , frontOi TheQueue, queueCapaci ty ; publ i c i nt noOfUsers InQueue ( ) { . . . }
Słowo oznaczające kolejkę( ang.
queue) występuje w trzech postaciach: Q, Queue oraz queue.
Ponieważ jednak dostęp do kolejek można uzyskać wyłącznie przy użyciu zmiennej typu
UserQueue,
w nazwach składowych w ogóle nie trzeba używać żadnego oznaczenia kolejki, wy
starczy sam kontekst, w związku z czym zapis
queue . queueCapaci ty jest zbędny. Lepsza jest taka wersja:
cl ass UserQueue { i nt ni terns , front , capaci ty ; publ i c i nt nusers ( ) { . . } .
T eraz można pisać instrukcje tego rodzaju:
queue. capaci ty++ ; n = queue.nusers ( ) ; N ie straciliśmy nic z kl arowności. A le to nie wszystko, co należy tu poprawić
-
i t ems
i u sers oznaczają to samo, a do oznaczania jednego pojęcia należy używać tylko jednej nazwy.
Do nazywania funkcji używaj czasowników. N azwyfu nkcji powinny tworzyć
czasowniki ozna
czające aktywne czynności, po których mogą występować rzeczowniki:
now = date . getT i me ( } ; putchar( ' \n ' } ; N azwy fu nkcji zwracających wartości logiczne ( prawda lub fałsz) nie powinny pozostawiać wątpl iwości co do sposobu interpretacji wyniku. N a przykład instrukcja
i f (checkoctal (c) ) . . . nie wskazuje, która wartość jest prawdą, a którafałszem, natomiast
i f ( i soctal (c) ) . . .
15
1.1. NAZWY
jasno sygnalizuje, że fu nkcja zwraca prawdę, jeśli argument jest w formacie ósemkowym, lub fałsz w przeciwnym razie.
Dbaj o precyzję.
N azwa to nie tylko etykieta, lecz także przekaźnik informacji dla czytającego.
Jeśli nazwy są mylące, mogą prowadzić do powstawania trudnych do rozwikłania błędów. Jeden z nas napisał i przez wiele lat rozpowszechniał makro o nazwie i soc ta 1 o następującej niepoprawnej implementacji:
#defi ne
i soctal (c) ( (c )
>=
'O'
&&
(c)
<=
181)
Poprawnie jest:
#defi ne
i soctal (c) ( (c)
>=
' O' &&
(c)
<=
'7')
N azwa w tym przypadku była poprawna, ale gorzej było z implementacją. Łatwo zamasko wać dobrze dobraną nazwą błędy w implementacji. Oto przykład, w którym nazwa i kodźródłowy pozostają w wyraźnej sprzeczności:
publ i c boo l ean i nTab l e (Obj ect obj ) i nt j = thi s . getlndex(obj ) ; return (j == nTab l e) ;
F unkcja o nazwie getlndex zwraca wartość z przedziału od zera do n Tab 1 e - 1, jeśl i znajdzie obiekt, lub nTabl e w przeciwnym razie. Wobec tego wartość logiczna zwracana przez i
nTabl e
jest przeciwieństwem tego, na co wskazuje nazwa. W trakcie pisania kodu coś takiego raczej nie wywoła problemu, al e gdy później ktoś inny zechce zmodyfikować ten program, kłopoty są murowane.
Ćwiczenie I.I. Skomentuj dobór nazw i wartości w poniższym kodzie. ? ?
#defi ne TRUE O #defi ne FALSE 1
?
i f ( (eh = getcha r ( ) ) not_eof = FALSE;
EOF)
Ćwiczenie 1.2. Popraw t ęfu nkcję: i nt smal l er (char * s , char *t) i f (strcmp ( s , t) < 1) return l; el se return O ;
Ćwiczenie 1.3. Przeczytaj ten kod n a głos: i f ( (fal l oc (SMRHSHSCRTCH , S_FEXT I 0644 , MAXROOOHSH) ) < O)
16
1. STYL
1.2. Wyrażenia i instrukcje Podobnie jak nazwy należy dobierać w taki sposób, aby jak najbardziej ułatwiały zrozumienie kodu, wyrażenia i instrukcje trzeba pisać tak, by ich znaczenie było możliwie jak najbardziej przejrzyste. Pisz najprostszy kod, który wystarczy do wykonania określonego zadania. Wokół operatorów wstawiaj spacje, aby zaznaczyć grupowanie argumentów, a mówiąc bardziej ogólnie - stosuj czytelne formatowanie. M oże to jest oczywiste, ale bardzo pomaga. To tak jak utrzy mywanie porządku na biurku ułatwia znajdowanie na nim rzeczy. Jednak w odróżnieniu od przykładu z biurkiem, istnieje duże prawdopodobieństwo, że ktoś będzie analizował Twoje programy.
Stosuj wcięcia, aby uwidocznić strukturę kodu. N ajłatwiejszym sposobem na sprawienie,
aby
struktura kodu mówiła sama za siebie, jest zastosowanie wcięć . Poniżej widać przykład złego formatowania:
for (n++ ; n
for (n++ ; n < 100; fi el d [n++] = ' \O ' ) * i = ' \O ' ; return ( ' \n ' ) ; Jeszcze lepiej, gdybyśmy przypisanie umieścili w treści pętli i oddzielili inkrementację, dzięki czemu pętla przybrałaby bardziej typowąformę i była łatwiejsza do zrozumienia:
for (n++ ; n < 100; n++) fi el d [n] = ' \O ' ; *i = ' \O ' ; return ' \n ' ;
Naturalnie formatuj wyrażenia.
Pisz wyrażenia tak, aby dały się odczytać na głos. Do naj
trudniejszych do zrozumienia zaliczają się wyrażenia zawierające negację:
i f ( ! (bl ock_i d < actbl ks) I I ! (bl ock_i d >= unbl ocks ) )
Oba testy są zapisane z negacją, chociaż żaden nie musi. Jeśli pozamieniamy relacje, to bę dziemy mogli zapisać wyrażenie bez negacji:
i f ( (bl ock_i d >= actbl ks) I I (bl ock_i d < unbl ocks ) )
Teraz kod czyta się naturalnie.
Stosuj nawiasy, aby uniknąć dwuznaczności. N awiasy służą do grupowania elementów i mo gą pomóc w wyklarowaniu wyrażeń nawet wówczas, gdy nie sąformalnie wymagane. W powyż szym wyrażeniu wewnętrzne nawiasy nie były konieczne, ale też i w niczym nie zaszkodziły.
17
1.2. WYRAŻENIA I INSTRUKCJE
Doświadczeni programiści mogliby je opuścić, bo wiedzą, że operatory relacji( <, <=, ==, >, >=) mają wyższy priorytet od operatorów logicznych( &&i11). Jeśli jednak w wyrażeniu używane są niepowiązane ze sobą operatory, warto zastosować nawiasy. W języku C i jemu podobnych występuj ą bardzo poważne problemy z określaniem kolejności wykonywania działań, przez co niezwykle łatwo popełnić błąd. Ponieważ operatory logiczne wykazują ściślejsze wiązanie niż operator przypisania, w większości wyrażeń, w któ rych są one stosowane razem, nawiasy są obowiązkowe:
whi l e ( ( c = getchar( ) ) ! = EOF)
Operatory bitowe & i
maj ą niższy priorytet od operatorów relacj i, takich jak==, a więc
wbrew pozorom wyrażenie
?
i f (x&MASK == BITS)
zostanie zinterpretowane jako
i f (x & {MASK==BITS) )
co z pewnością nie było zamierzeniem programisty. Ze względu na jednoczesną obecność operatorów bitowych i relacji wyrażenie należy uzbroić w nawiasy:
i f ( (x&MASK) == BITS)
N awiasy mogą pomóc w zrozumieniu wyrażenia nawet wówczas, gdy nie są formalnie wy magane. W poniższym kodzie nie musimy stosować nawiasów:
l eap_year
=
y % 4 == O && y % 100 ! = O I I y % 400 == O ;
alej eśli ich użyj emy, wyrażenie będzie o wiele bardziej klarowne:
l eap_year
=
( (y%4 == O) && (y%100 ! = O) ) I I {y%400 == O} ;
Usunęliśmy też niektóre spacj e - grupowanie argumentów operatorów o wyższym priory tecie również pomaga czytaj ącemu w rozpoznaniu struktury.
Dziel skomplikowane wyrażenia. Języki C, C+ +
i Java mają ekspresywną składnię i bogaty
zestaw operatorów, przez co łatwo jest dać się ponieść i upchać wszystko, co można, w jednej konstrukcji. Poniższe wyrażenie jest bardzo zwięzłe, ale upchano w nim zbyt wiele operacji:
*x += {*xp= (2*k < (n-m) ? c [k+l] : d [k--] } ) ; Łatwiej to zrozumieć po podzieleniu na kilka wierszy:
18
1. STYL
i f (2*k < (n-m) *xp = c [k+l ) ; el se *xp = d [k--] ; *x += *xp ;
Pisz klarownie.
Programiści mają niespożyte pokłady kreaty wnej energii, którą często wyko
rzystują na pisanie jak najzwięźl ejszego kodu al bo znajdowanie sprytnych sztuczek pozwal ają cych osiągnąć żądany wynik. Czasami jednak źl e inwestują swoje tal enty, ponieważ cel em po winno być uzyskanie przejrzystego, a nie sprytnego kodu. Co robi poniższy misternie utkanyfr agment kodu?
subkey = subkey >> (bi toff - ( (b i toff >> 3) << 3 ) ) ; N ajgłębiej położone wyrażenie przesuwa wartość
b i to ff
o 3 bity w prawo. Wynik ten zo
staje następnie przesunięty z powrotem w l ewo, co powoduje zastąpienie trzech przesuniętych bitów zerami. Ten wynik z kol ei zostaje odjęty od oryginal nej wartości, czego rezul tatem są 3 dol ne bity wartości bi
to ff. Bity te są użyte do przesunięcia wartości s ub key
w prawo.
Wyrażenie to jest równoważne z poniższym:
sub key = subkey >> (bi toff & Ox7 ) ; A by zrozumieć pierwszą wersję, trzeba się trochę pogłowić . Druga natomiast jest krótsza i kl arowniejsza. Doświadczeni programiści zapisują to jeszcze krócej, używając operatora przypisania:
subkey >>= bi toff & Ox7 ; N iektóre konstrukcje sprawiają wrażenie, jakby wręcz prosiły się o błędy. Zwłaszcza kod z operatorem ? : bywa zagadkowy:
chi l d= ( ! LC&& ! RC) ?O: ( ! LC?RC : LC) ; Bez prześl edzenia prawie wszystkich możl iwych ścieżek wykonania tego wyrażenia nie da się chyba rozszyfrować, co ono robi. Poniższa postać jest dłuższa, al e znacznie łatwiejsza do rozszyfr owania, ponieważ wyraźnie są w niej zaznaczone ścieżki wykony wania:
i f ( LC == O chi l d = el se i f (LC chi l d el se chi l d
&& RC == O) O; = = O) RC ; LC ;
Operator ? :
dobrze nadaje się do pisania krótkich wyrażeń, w których pozwal a jednym
wierszem zastąpić cztery wiersze instrukcji i f-el se:
max = ( a > b) ? a
:
b;
1.2.
19
WYRAŻENIA I INSTRUKCJE albo
pri ntf ( "Li sta zawi era %d el ement%s\n" , n , n==l ? " " : " 6w" ) ; N ie należy goj ednak traktować j ako tradycyj nego zastępnika instrukcj i warunkowych. Klarowność to nie to samo co zwięzłość. Czasami klarowniej szy kod j est krótszy, j ak było w przypadku przesuwania bitów, ale może też być dłuższy, j ak w przypadku wyrażenia warun kowego zapisanego za pomocą instrukcj i i f-el s e. N ależy się kierować tym, która z wersj i za pewnia większą czytelność kodu.
Uważaj na efekty uboczne. N iektóre operatory, takie j ak++ , powoduj ą powstawanie efektów ubocznych nie tylko zwracaj ą wartość, lecz także modyfikuj ą wartość zmiennej, względem -
której zostały użyte. Ef ekty uboczne w pewnych sytuacj ach są bardzo wygodne, ale mogą też wywoływać problemy, j eśli czynności pobierania wartości i aktualizowania zmiennej nie zosta ną wykonane j ednocześnie. W j ęzykach C i C++ kolej ność wykonywania ef ektów ubocznych niej est określona, przez co wielokrotnie użyta w poniższym przykładzie instrukcj a przy pisania może zwrócić nieprawidłowy wynik: '
s t r [ i ++] = str[i ++] = '
;
I ntencj ą autora było zapisanie spacj i w dwóch kolej nych elementach tablicy str. A le po nieważ nie wiadomo, kiedy wartość pominięta, przez co wartość
s t r [ i ++] str[i ++]
I I
i
i
zostanie zaktualizowana, j edna pozycj a w str może zostać
zwiększy się tylko o 1 . Lepiej podzielić to na dwie instrukcj e:
'• .
1 •
.
N awetj eślij est tylkoj edna inkrementacj a, przypisanie może zwracać różne wyniki:
?
array [ i ++] = i ; Gdyby zmiennai miała wartość początkową3, to element tablicy może mieć wartość
3
lub4.
N ie tylko inkrementacj a i dekrementacj a maj ą ef ekty uboczne. I nnym źródłem tego typu niespodzianek są operacj e wej ścia i wyj ścia. Poniższy kod stanowi próbę odczytania dwóch powiązanych ze sobą liczb z wej ścia standardowego:
·
scanf( "%d %d " , &yr, &profi t [yr] ) ; Problem polega na tym, że inna część wyrażenia modyfi kuj e zmienną yr, a inna j ej używa. W związku z tym wartość
profi t [yr]
może być poprawna tylko wówczas, gdy nowa wartość
yr
będzie równa starej . M oże się wydawać, że źródło problemu tkwi w kolej ności ewaluowania argumentów, ale tak naprawdę chodzi o to, iż wszystkie argumenty fu nkcj i scanf są ewalu owane przed j ej wywołaniem, a więc wartość
&profi t [yr]
będzie zawsze ewaluowana przy
użyciu starej wartości yr. Tego rodzaj u problem może poj awić się w prawie każdym j ęzyku programowania. Rozwiązaniemj estj ak zwykle podzielenie wyrażenia na prostsze części:
scanf ( " %d " , &yr) ; s canf ( " %d " , &profi t [yr] ) ;
20
1. STYL Zachowaj szczególną ostrożność przy każdym wyrażeni u z ef ektami ubocznymi .
Ćwiczenie
1.4. Popraw poni ższefr agmenty kodu:
'y ' 11 c
'y') )
i f ( ! (c return ;
==
l ength
( l ength < BUFSIZE)
fl ag
fl ag ? O
quote = (*l i ne ==
:
'"'
==
l ength
BUFS I Z E ;
1;
) ?
O·.
i f (val & 1 ) b i t = l; el se b i t = O;
Ćwiczenie 1.5. Znajdź błąd. i nt read ( i n t * i p) { s canf ( " %d " , i p) ; return * i p ; ? ?
i nsert (&graph [vert] , read(&val ) , read(&ch) ) ;
Ćwiczenie 1.6.
Wymi eń wszystki e możli we wyni ki tego kodu przy zastosowani u różnych ko
lejności wykonywani a dzi ałań:
n = l; pri ntf ( "%d %d\n " , n++, n++) ; Wypróbuj go w jak najwi ększej li czbi e kompi latorów, aby zobaczyć , co będzi e si ę dzi ało w praktyce.
1.3. Spójność i idiomy Zachowani e spójności jest jednym z warunków napi sani a dobrego programu. Jeśli formatowa ni e zmi eni a si ę bez żadnej logi ki , pętle raz bi egną w górę poi ndeksach tabli cy, aby zaraz potem wracać w drugą stronę, do kopi owani a łańcuchów raz wykorzystuje si ę funkcję st repy, a gdzi e i ndzi ej znowu pętlę for, to wszystko bardzo utrudni a zrozumi eni e tego, co si ę dzi eje w kodzi e. Jeżeli natomi ast każda operacja jest zawsze wykonywana jednakowo, to jakakolwi ek zmi ana od razu podpowi ada, że wystąpi ła jakaśi stotna różni ca.
Spójnie stosuj wcięcia i nawiasy klamrowe. Wi adomo,
że wci ęci a pozwalają uwypukli ć struk
turę kodu, ale jak najlepi ej je stosować ? Czy klamra otwi erająca powi nna znajdować si ę w tym samym wi erszu, co i nstrukcja i f, czy w następnym? Programi ści od dawna toczą zażarte dys kusje o układzi e kodu, ale tak naprawdę i stotny jest ni e tyle konkretny sposób stosowani a
21
1 .3. SPÓJNOŚĆ I IDIOMY
wcięć, ile trzymanie się przez cały czas jednego stylu. Wybierz jedną metodę, najlepiej naszą, stosuj ją zawsze i wszędzie i przestań marnować czas na jałowe dyskusje. Czy należy wstawiać klamry nawet wówczas, gdy nie są wymagane? Klamry, podobnie jak nawiasy, mogą pomóc rozwiązać różne niejasności i wyklarować kod. Wielu doświadczonych programistów chcąc zachować spójność formatowania kodu, zawsze umieszcza w klamrach treść pętli i instrukcji i f. Jeśli jednak treść ta składa się z jednej instrukcji, klamry nie są wymagane i czasami je pomijamy. Jeżeli również zdecydujesz się na ten krok, uważaj, żeby nie usunąć klamer w miejscu, w którym są potrzebne, jak w przedstawionym niżej przykładzie „ wiszą cegoel se":
i f (month == FEB) { i f (year%4 == O) if (day > 29) l egal FALSE ; el s e i f {day > 28) l egal = FALSE ; =
Wcięcia w tym przypadku s ą mylące, ponieważ instrukcja el s e należy do wiersza
i f (day
>
29)
i kod zawiera błąd. Dlatego jeśl i po instrukcji i f występuje od razu druga taka instrukcja, zawsze używaj klamer:
i f (month == FEB) { i f (year%4 == O) { i f (day > 29) lega 1 = FALS E ; el se { i f (day > 28) l egal = FALSE ;
Ryzyko wystąpienia tego rodzaju błędów składni można zmniejszyć poprzez wykorzystanie odpowiednich narzędzi do edycji kodu. Kod ten trudno jednak zrozumieć, nawet mimo poprawienia błędu. Będzie łatwiej, jeśli do przechowywania liczby dni w lutym użyjemy zmiennej:
i f (month == FEB) i nt nday ; nday = 28; i f (year%4 == O) nday = 29 ; i f (day > nday) l egal = FALSE ;
22
1. STYL Ten kod nadal jest błędny, ponieważ rok 2000 jest przestępny, natomiast 1900 i 2100
-
nie, ale taką strukturę znacznie łatwiej jest już doprowadzić do ostatecznego porządku. Przy okazji: jeśli pracujesz na nie swoim kodzie, zachowaj styl, który był w nim stosowany. Zmiany wprowadzaj przy użyciu zastosowanych w nim konwencji(a nie swoich), nawet jeśliCi się nie podobają. Spójność kodu źródłowego jest ważniejsza od Twojego komfortu, ponieważ ułatwia życie tym, którzy będą to czytać poTobie.
Używaj idiomów.
W językach programowania, podobnie jak w językach naturalnych, wystę
pują idiomy, czyli typowe sposoby pisania określonych partii kodu, z których korzystają do świadczeni programiści. Kluczową rolę w nauce każdego języka odgrywa zaznajomienie się z występującymi w nim idiomami. Jednym z najczęściej spotykanych idiomów jest format pętli. Jako przykład niech posłuży nam kod napisany w języku C, C++ lub Java, nadający wartość n elementom tablicy. Ktoś mógłby napisać taką oto pętlę:
i = O; whi l e ( i < = n - 1 ) array [i ++] 1. O; albo taką:
for (i = O; i < n ; ) array [i ++] = 1 . 0 ; albo nawet taką:
for ( i = n; - - i >= O ; array [ i ] = 1 . 0 ; Wszystkie te pętle są poprawne, ale idiomatycznie zapisuje się to tak:
for (i = O ; i < n; i ++) array [ i ] = 1 . 0 ; Wybór ten nie jest arbitralny. Pętla ta odwiedza p o kolei każdy element tablicy n elementów indeksowanej od O don-1. Cały kod sterujący pętlą znajduje się w części for, pętla przechodzi od najmniejszego indeksu do największego, a do aktualizowania zmiennej pętlowej został użyty bardzo idiomatyczny operator++ . Zmienna indeksowa po zakończeniu działania pętli ma znaną wartość większą o jeden od rozmiaru tablicy. Doświadczeni użytkownicy języka rozpoznają ten idiom w mgnieniu oka i potrafią go zapisać bez zastanowienia.
w językuc+ +i wJavie często dodaje się jeszcze deklarację zmiennej pętlowej:
for ( i nt i = O ; i < n ; i ++) array [ i ] = 1 . 0 ; Oto standardowa pętla do przemierzania list w językuC:
for (p = l i st ; p ! = NULL; p = p->next)
1.3. SPÓJNOŚĆ I IDIOMY
23
T u ta kże sterowa nie pętlą mieści się w części f or . W roli nieskończonej pętli pref erujemy
for
(; ;)
a le pętla
whi l e ( l )
jest ró wnież popularna . Za wsze używa j jednej z tych wersji. Ta kże wcięcia powinny być stosowa ne idioma tycznie. P oniższy nietypowy pionowy za pis utrudnia zrozumienie kodu, a na wet ba rdziej przypomina trzy niezwiąza ne ze sobą instrukcje niż pętlę:
for ( ap = arr; ap < arr + 128 ; *ap++ = O
)
O wiele ła twiej jest odczyta ć zna czenie ze sta nda rdowego za pisu pętli:
for (ap *ap
= =
arr; ap < arr+l28; ap++) O;
P ona dto rozproszone formy za pisu często powodują podzielenie kodu na kilka stron l ub ekra nó w, co ró wnież osła bia czytelność. Kolejnym często spotyka nym idiomem jest wsta wia nie przypisa nia do wa runku pętli:
whi l e ( (c = getchar ( ) ) ! = EOF) putchar (c) ; I nstrukcji do-whi 1 e używa się zna cznie rza dziej niż whi 1 e i for, poniewa ż w niej test jest przeprowa dza ny na sa mym końcu, a więc jej kod za wsze zosta je wykona ny przyna jmniej ra z. W wielu przypa dka ch za stosowa nie tego rodza ju konstrukcji jest ró wnozna czne z proszeniem się o kłopoty, ta k ja k w poniższej wersji pętli zfu nkcjągetchar:
do c = getcha r ( ) ; putchar (c) ; whi l e (c ! = EOF) ; P oniewa ż test zosta je wykona ny po wywoła niu fu nkcji putchar, pętla ta za pisuje jeden nie potrzebny zna k wyjściowy. P ętli do-wh i 1 e na leży używa ć tylko wó wcza s, gdy instrukcje pętlowe muszą zosta ć wykona ne przyna jmniej ra z. Później zoba czymy kilka odpowiednich przykła dó w.
24
1. STYL
Jedną z zalet konsekwent nego t rzym ania się idiom ów j est t o, że od razu m ożna wizualnie wychwycić wszelkie niest andardowe pęt le, kt óre częst o oznaczaj ą kłopot y:
i nt i , *i Array , nmemb ; i Array = mal l oc (nmemb * si zeof ( i nt) ) ; for ( i = O ; i <= nmemb ; i ++) i Array [ i ] = i ; F unkcj a ma 1 1 oc alokuje m iej sce w pam ięci dla nmemb elem ent ów, od elem ent u i Array [OJ do i Array [nmemb - 1 ] , ale ponieważ w warunku pęt li zast osowano operat or relacji<=, pęt la wyj dzie poza granicę t ablicy i zniszczy t o, co znaj duje się za nią w pam ięci. N iest et y, t ego t ypu błędów nie wykrywa się zwykle od razu, lecz dopiero wówczas, gdy wyrządzą dużo szkód. w językach c i c+ + są t eż idiom y dot yczące alokowania pam ięci dla łańcuchów i opero wania na niej. Kod, w kt órym się z nich nie korzyst a, częst o kryj e błędy:
char *p , buf [256] ; get s ( buf) ; p = mal l oc ( strl en (buf) ) ; strcpy ( p , buf) ; N igdy nie używaj fu nkcji gets, ponieważ uniem ożliwia ona określenie lim it u ilości wczy t ywanych danych. T o powoduj e probl em y z bezpieczeńst wem, do kt órych wrócim y jeszcze w rozdziale6.
-
pokażem yt am, że zawsze lepszą alt ernat ywąj est fu nkcja fgets. T o j ednak nie
j edyny problem . F unkcj a strl en nie liczy znaku ' \0 ' kończącego łańcuch, podczas gdyfu nkcj a
strcpy
go kopiuj e. Z t ego powodu zost anie alokowana zbyt m ała ilość pam ięci i fu nkcj a strcpy
zapisze dane za przydzielonym obszarem . I diom w t ym przypadku wygląda t ak:
p = mal l oc (strl en (buf) +l) ; strcpy ( p , buf) ; lub t ak w językuC + + .
p = new char [strl en (buf) +l] ; st rcpy ( p , buf) ; Jeśli nie widzisz inst rukcj i+ 1, podwój swoj ą czujność . WJavie t en problem nie wyst ępuje, ponieważ w niej łańcuchy nie są reprezent owane w po st aci t ablic zakończonych zerem . T akże indeksy t ablicy są sprawdzane, a więc nie da się wyj ść pozaj ej granice. w większości środowisk c i c+ + m ożna t ego problem u łat wo uniknąć dzięki zast osowaniu fu nkcj i bibliot ecznej
strdup,
kt óra t worzy kopię łańcucha przy użyciu fu nkcji ma 1 1
oc
i strcpy.
N iest et y, fu nkcja s trdup nie należy do st andardu ANSI C. Przy okazji zauważm y, że ani wersj a oryginalna, ani poprawiona nie sprawdzają wart ości zwracanej przez fu nkcję ma 1 1 oc . Pom inęl iśm y t en szczegół, aby skoncent rować się na naj waż niejszej kwest ii, ale w realnym świecie zawsze należy sprawdzać wynik zwracany przez fu nkcj e
ma 1 1 oc, rea 1 1
oc,
st rdup
i wszelkie inne alokujące pam ięć . .
25
1.3. SPÓJNOŚĆ I IDIOMY
Do podejmowania wielokierunkowych decyzji używaj instrukcji i f-el se. Decyzje wielo kie f . . . el se i f . . . el se:
r unko wei diom atycznie wyr aża si ę w po staci łańcuchai nstr ukcji i
i f (warunek,) instrukcja,
el se i f (warunek,) instrukcja,
el se i f (warunek.) instrukcja.
el se instrukcja-domyś lna
Warunki są spr awdzane o d gór y. Dla pierwszego, któr y zo stanie spełnio ny, wyko nywana jego instrukcja, a r eszta ko nstr ukcji zo staje pomi nięta. I nstr ukcja mo że być po jedyncza
jest lub
składać si ę z wi el u instr ukcji wydzielo nychm iędzy klamr ami. Ostatnia klauzul a el
se
o bsługuje sytuację „ dom yślną'', czyli jej instr ukcje są wyko nywane
wówczas, gdy żadna z po zo stałych o pcji nie zo stani e wybr ana. Jeśli nie pr zewiduje si ę takiej sytuacji, tę o statni ą instr ukcję mo żna o puści ć, al e war to ją po zo stawić z kom uni katem o błę dzi e na wypadek zdar zeni a, któr e „ ni emi ało pr awam ieć m iejsca" . Jeżeli cho dzi o str uktur ę ko du, l epiej wszystki e klauzule el
se
wyr ównać w pio ni e, ni ż sze
r ego wać je w jednej li nii z instr ukcją i f. Taki e pio no we ustawieni e klauzul po dkr eśla sekwen cyjny spo sób pr zepro wadzania spr awdzeń or az zapo bi ega po wstawaniu zbyt długi ch wi er szy ko du. Często widząc skom pliko wane, wi elo po ziomo wo zagnieżdżo ne str uktur y i nstr ukcji
i f,
na
l eży spo dziewać się pr zynajm niej ni ezgr abnego stylu pro gr amo wania, a w najgor szych pr zy padkach nawet po ważnych błędów.
?
? ? ?
i f (argc == 3 ) i f ( (fi n = fapen (argv [l ] , " r " ) ) ! = NU LL) if ( ( faut = fapen (argv [2] , "w" ) ) != NULL) whi l e ( (c = getc (fi n ) ) != EOF) putc ( c, faut) ; fcl ose (fi n) ; fcl ose (fout) ; el se pri ntf ( " N i e można otworzyE pl i ku wyj ści owego %s\n " , argv [2] ) ; el se pri ntf ( " N i e można otworzyE pl i ku wej ś c i owego %s\n" , argv [l ] ) ; el se pri ntf ( " Użyc i e : cp pl i kwej ści owy pl i kwyj ś c i owy\n " ) ; Ta zbi er anina instr ukcji
if
zm usza nas do pr zepro wadzeni a w m yślach sym ulacji ko lej
nych testów, aby m óc o kr eśli ć, któr em u z nich o dpo wi adają po szczególne instr ukcje ( jeśli uda nam się to spami ętać). Po nieważ pr zynajm ni ej jedna czynno ść m usi zo stać wyko nana, pr zyda łaby nam się tu instr ukcja el
se-i f.
Ko d ten mo żem y wyklaro wać po pr zez zm ianę ko lejno ści
po dejm o wani a decyzji, a pr zy o kazji po zbędzi em y si ę dzięki tem u wycieku zaso bów, któr y kr ył si ę w pi er wo tnej wer sji:
i f (argc ! = 3 ) pri ntf ( " Użyc i e : cp pl i kwej ści owy pl i kwyj ś c i owy\ n " ) ; el se i f ( (fi n = fopen (argv [ l ] , " r" ) ) == NULL) pri ntf ( " N i e można otworzyE pl i ku wej ś c i owego %s\n " , argv [l ] ) ;
26
1.
STYL
el se i f ( (faut = fapen(argv [2] , "w" ) ) == NULL) { pri ntf("Ni e można otworzyć pl i ku wyj ści owego %s\n " , argv2] ) ; fcl ose(fi n ) ; el se { whi l e ( (c = getc (fi n ) ) ! = EOF) putc ( c , faut) ; fcl os e ( fi n) ; fcl ose (faut) ;
Cz yt amy po kolei instr ukcj e spr awdz aj ące, aż z naj dz iemy t aką, kt ór ej war unek j est speł niony, wykonuj emy odpowiadaj ące mu instr ukcj e i kont ynuuj emy wykonywani e pr ogr amu od miej sca z a ost at ni ą klauz ulą el se. Ogól na z asada j est t aka, aby instr ukcj e z naj dowały si ę j ak naj bliż ej decyzj i, z kt ór ą są z wi ąz ane. I nnymi słowy, z awsz e gdy prz epr owadz asz j akiś t est , wy konaj t eż j akieś cz ynności . Z agmat wany kod moż e powst awać t akż e w wyniku ni eudanych pr ób wi elokr ot nego wyko rz yst aniafr agment ów pr ogr amu:
swi tch (c) case ' - ' : case ' + ' : case ' . ' : defaul t :
{ s i gn = - 1 ; c = getchar ( ) ; break; if ( ! i sd i g i t (c ) ) return O ;
W t ym kodzi e w cel u unikni ęcia powt órz enia j ednego wi er sz a kodu z ast osowano bez po śr ednie prz ej ście z j ednej klauz uli case instr ukcj i wh i l e do nast ępnej ( ang. fall-through). Taki sposób pi sani a kodu t eż ni e j est i di omat ycz ny, poni eważ kl auz ul e case pr awi e z awsz e powi nny kończ yć się i nstr ukcj ą break. N ieli cz ne prz ypadki , w kt ór ych j ej pomi nięci e j est uz asadni one, nal eż y oz nacz ać st osownym koment arz em. Ot o bar dzi ej tr adycyj ny i ni eco dłuż sz y, ale z a t o bar dz iej prz ejrz yst y sposób z api sani at ego kodu:
swi tch (c) { case ' ' : s i gn = - 1 ; -
/*przejście bezpośrednie *I
?
case ' + ' : c = getchar() ; break; case ' ' · brea k ; defaul t : i f ( ! i sd i g i t (c ) ) return O ; break ;
Ni ewielki e z większ enie ilości kodu spowodowało ogr omną r óż nicę, j eśli ch odz i o j ego kla r owność . Jednak w prz ypadku t aki ch ni et ypowych str ukt ur naj lepiej pod wz ględem prz ejrz y st ości spr awdz aj ą si ę instr ukcj eel se- i f:
27
1.3. SPÓJNOŚĆ I IDIOMY i f ( C == I - I ) { s i gn = - 1 ; c = getchar () ; el se i f (c == ' + ' ) c = getchar () ; el se i f (c ! = ' . ' && ! i sdi gi t (c ) ) { return O ;
Kla mry ota cza jące jednowi ersz owe bloki uwypuklaj ą równoległą st rukt urę kodu. Prz ypa dek, w kt órym do prz yj ęcia j est opusz cz eni e i nst rukcj i bre a k w kla uz ula ch c a s e inst rukcj i
swi tch,
t o syt ua cja, gdy kilka t ych kla uz ul ma ta ki sa m kod. Sta nda rdowo za pisuje
si ęt ota k:
case case case
'O' : '1' : '2' : brea k ;
N iet rz eba doda wa ć ża dnego komenta rza .
Ćwiczenie 1.7. Na pi sz t efra gment y kodu w jęz ykuCIC+ +
w ba rdzi ej cz yt elny sposób:
i f ( i stty (stdi n ) ) el se i f (i stty( stdout ) ) ; el se i f ( i stty (stderr) ) el se return (O) ; i f (retval ! = SUCCESS) { return (retval ) ; /* Wszystko się udało! *I return SUCCESS ; for ( k = O ; k++ < 5 ; x += dx) scanf ( "%l f" , &dx) ;
Ćwiczenie 1.8.
Zna jdź błędy w t ym fra gmencie progra mu w j ęz ykuJa va i na pi sz go ponowni e
prz y użyciu i diomat ycz nej pęt li :
i nt count = O ; whi l e (count < total ) count++ ; i f (thi s . getName (count) return (true) ? ?
nametabl e . userName ( ) ) {
28
1. STYL
1.4. Makra w roli funkcji Starsi programiści języka C mają w zwyczaju dla krótkich i często wykonywanych obliczeń pisać makra zamiast funkcji. Powszechną aprobatą cieszą się operacje wejściowe typu get char i testy znaków w rodzaju i sdi g i t. Robi się to ze względu na wydajność, ponieważ makra są pozba wione typowego dla wywołania funkcji narzutu. Argument ten jednak był słaby już krótko po powstaniu języka C, czyli w czasach, gdy komputery były wolne i wywołanie funkcji stanowiło dla nich nie lada wysiłek. Obecnie to nie ma już żadnego znaczenia. Przy dzisiejszych kompu terach i kompilatorach wady stosowania makr przewyższają płynące z ich użycia korzyści.
Unikaj używania makr w roli funkcji. W języku C+ + makra można z powodzeniem zastąpić funkcjami rozwijanymi w linii wywołania. W Javie w ogóle ich nie ma. W języku C powodują więcej problemów, niż są warte. Jednym z największych problemów, jakie sprawiają makra funkcyjne, jest to, że każdy pa rametr, który występuje w definicji częściej niż raz, może zostać ewaluowany również częściej niż raz. Jeśli argument wywołania będzie zawierał wyrażenie powodujące efekty uboczne, wy stąpi trudny do wykrycia błąd. Poniższy kod stanowi próbę implementacji jednego z testów znakowych z biblioteki
:
#defi ne i supper(c) ( (c) >= ' A ' && {c) <= ' Z ' ) Zauważmy, że parametr c występuje w treści makra dwa razy. Jeśli więc funkcja i s upper zostanie wywołana w następujący sposób:
whi l e (i supper (c = getchar() ) )
to za każdym razem, gdy zostanie wczytany znak większy od A lub równy A, nastąpi odrzu cenie wczytanego znaku i wczytanie kolejnego znaku w celu porównania go ze znakiem Z. Standard C został tak skonstruowany, że zezwala na pisanie funkcji jak i s upper jako makr, ale pod warunkiem, że każdy argument będzie ewaluowany tylko raz. Przedstawiona implementa cja tego warunku nie spełnia. Zawsze lepiej skorzystać z funkcji biblioteki ctype, niż implementować je własnoręcznie, a ponadto radzimy unikać zagnieżdżania procedur mających efekty uboczne, takich jak getchar. Jeśli przepiszemy kod przy użyciu dwóch wyrażeń zamiast jednego, to nie tylko zyska on na klarowności, lecz także my uzyskamy możliwość obsłużenia zdarzenia wystąpienia końca pliku:
whi l e ( (c = getch ar ( ) ) ! = EOF && i supper ( c ) )
Czasami wielokrotna ewaluacja parametru powoduje nie tyle konkretny błąd, ile obniżenie wydajności programu. Spójrzmy na poniższy przykład:
? #defi ne ROUND_TO_INT (x) ( ( i nt) ( (x)+ { { (x)>0) ?0 . 5 : -0 . 5 ) ) ) s i ze = ROUND_TO_I NT(sqrt (dx* dx + dy*dy) ) ;
29
1.5. LICZBY MAGICZNE
Ten kod będzie powtarzał czynności obliczania pierwiastka kwadratowego tyle razy, ile potrze ba. Nawet przy prostych argumentach złożone wyrażenie, takie jak treść makra ROUND_TO_ INT, przekłada się na wiele instrukcji, które powinny być umieszczone w jednej funkcji, możliwej do wywołania w razie potrzeby. Zastępowanie makra odpowiednim kodem przy każdym jego wystąpieniu powoduje rozdęcie programu po kompilacji (ten sam problem dotyczy funkcji rozwijanych w języku c+ + ) .
Treść i argumenty makr umieszczaj w nawiasach. Jeśli musisz używać makr, rób to z rozwagą. Działają one na zasadzie podmiany tekstu, tzn. parametry użyte w definicji są zastępowane przez argumenty wywołania, a następnie otrzymany kod jest wstawiany w postaci tekstu w miejsce oryginalnego wywołania. To właśnie ta problematyczna cecha odróżnia je od funkcji. Wyrażenie
1 / square(x) zadziała poprawnie, jeśli square będzie funkcją, ale jeśli będzie makrem takim jak poniżej
#de fi ne square (x) (x) * (x) zostanie rozwinięte do błędnej postaci
1 / (x) * (x) Należałoby je napisać tak:
#defi ne square (x)
( (x) * (x) )
Wszystkie użyte nawiasy są tu niezbędne, ale nawet ich prawidłowe zastosowanie nie chro ni nas przed problemem wielokrotnej ewaluacji. Jeśli w programie występuje jakaś pochłania jąca dużo zasobów lub często używana operacja, najlepiej jest ją zdefiniować w postaci funkcji. W języku C+ + makra można zastąpić funkcjami rozwijanymi, które pod względem wydaj ności oferują te same korzyści co makra, a są pozbawione ich wad składniowych. Dobrze nadają się do definiowania niewielkich operacji ustawiających lub pobierających pojedynczą wartość.
Ćwiczenie 1.9. Znajdź problemy w poniższej definicji makra. #defi ne ISDIGIT(c) ( (c >= ' D ' ) && (ce <= ' 9 ' ) ) ? 1
:
D
1.5. Liczby magiczne Liczby magiczne to wszelkie stałe, rozmiary tablic, pozycje znaków, współczynniki konwersji i inne wartości liczbowe występujące w kodzie w postaci literałów.
Nadawaj nazwy liczbom magicznym. Ogólnie można przyjąć, że każda liczba różna od O i 1 może być magiczna i powinna mieć swoją nazwę. Jeśli w kodzie programu występują gołe licz by, to nie wiadomo, skąd się wzięły ani co oznaczają, przez co trudno taki program zrozumieć i modyfikować. Poniższy fragment kodu źródłowego programu drukującego histogram często ści występowania liter w obsługiwanym za pomocą kursora terminalu o wymiarach 24 x 80 mógłby być znacznie klarowniejszy, gdyby nie cała masa użytych w nim magicznych liczb:
30
I.
fac = l i m / 2 0 ; i f (fac < 1 ) fac = l ;
STYL
I* Ustawienie współczynnika skalowania *I
I* Tworzenie histogramu *I
for
< 2 7 ; i ++ , j++) { ( i = O , col = O ; col += 3 ; k = 21 - ( l et [i ] / fac) ; I* star = ( l et [ i ] == O) ? ' ' for (j = k; j < 2 2 ; j ++) draw(j , col , star) ; I •
'
?
} draw (23 , 2 , ' ' ) ; l* Oznaczenie osi X */ for ( i = ' A ' ; i <= ' Z ' ; i ++) pri ntf( "%c i); • ,
W powyższym kodzie znajdziemy m. in. takie liczby: 20, 21, 22, 23 i 27. N a pewno są ze so bą jakoś powiązane. . . chyba. W istocie w tym programie kluczowe znaczenie mają tylko trzy liczby: 24 - liczba wierszy na ekranie, 80 - liczba kolumn na ekranie, oraz 26 - liczba liter w alfa becie. Ż adna z nich nie występuje jednak w podanym fr agmen� ie, co czyni te, które w nim są, jeszcze bardziej tajemniczymi. N adając najważniejszym liczbom w kodzie nazwy, sprawiamy, że jest on znacznie łatwiej szy do zrozumienia. Odkryliśmy np., że liczba 3 to wynik działania (80-1)/26, a tablica l et po winna mieć
26
elementów zamiast 27 ( pomyłka o jeden jest spowodowana najprawdopodobniej
przez to, że współrzędne ekranu są indeksowane od 1). Po wprowadzeniu kilku dodatkowych ulepszeń kod wygląda tak:
enum { MINROW MI NCOL MAXROW MAXCOL LABELROW NLET HEIGHT WIDTH };
= 1 1 = 24 = 80 = 1 = 26 = MAXROW - 4 , = (MAXCOL- 1 ) /NLET =
I* Górna krawędź *I I* Lewa la·awędź *I I* Dolna krawędź (<=) *I I* Prawa krawędź (<=) *I I* Położenie etykiet *I I* Liczba liter w alfabecie *I I* Wysokość słupków *I I* Szerokość słupków *I
fac = (1 i m + H E I GHT-1) / HEIGHT; /* Ustawienie współczynnika skalowania *I i f (fac < 1 ) fac = 1 ; /* Tworzenie histogramu *I for ( i = O ; i < NLET; i ++) { i f ( l et [i ] == O) conti nue; for (j = HEIGHT - l et [i ] /fac ; j < H EIGHT; j++) draw (j+l + LABELROW, ( i +l) *W I DTH , ' * ' ) ; } draw (MAXROW- 1 , MI NCOL+l , ' ' ) ; /* Oznaczenie osi X *I for ( i = ' A ' ; i <= ' Z ' ; i ++) pri ntf( "%c i); • ,
Teraz treść pętli głównej nie ma przed nami żadnych tajemnic. Została zapisana w postaci idiomatycznej, a jej zmienna sterująca iteruje od O do N LET, co wskazuje na to, że przetwarza elementy tablicy. Również wywołaniafu nkcji draw są jaśniejsze, ponieważ słowaMAXROW iMINCOL
1.5. LICZBY MAGICZNE
31
przypominają nam kolejność argumentów. Co najważniejsze, program w tej postaci można ła two dostosować do ekranu o innym rozmiarze lub innego zestawu danych. Liczby zostały od czarowane i program od razu stał się klarowniejszy.
Definiuj liczby jako stałe, a nie makra. Programiści C mają w zwyczaju definiować liczby magiczne przy użyciu dyrektywy #defi ne. Ponieważ jednak preprocesor języka C to potężne, ale tępe narzędzie, używanie makr w tej roli nie jest najlepszym rozwiązaniem, gdyż zmieniają one strukturę leksykalną programu. Wykorzystajmy możliwości, jakie oferuje nam język. W języ kach C i C + + stałe całkowitoliczbowe można definiować przy użyciu instrukcji en urn, co wi dzieliśmy w poprzednim przykładzie. Ponadto w języku C+ + można definiować stałe dowolnego typu przy użyciu słowa kluczowego con st:
const i nt MAXROW = 24, MAXCOL = 80 ; W Javie podobną rolę odgrywa słowo kluczowe fi na l :
stati c fi nal i nt MAXROW = 24, MAXCOL = 80 ; W języku C też występuje słowo kluczowe con st, ale zdefiniowanych przy jego użyciu war tości nie można używać do oznaczania granicy tablic, w związku z czym w tym języku pozo staje nam enum.
Używaj stałych znakowych zamiast całkowitoliczbowych. Funkcje w bibliotece i ich odpowiedniki służą do sprawdzania właściwości znaków. Jeśli napiszemy taki test:
i f (c >= 65 && c <= 90)
to całkowicie uzależnimy się od konkretnej reprezentacji znaków. Dlatego lepiej jest napi sać to tak:
i f (c >= ' A ' && c <= ' Z ' )
Wadą tej metody jest to, że jeśli w danym zestawie znaków litery nie są ustawione w sposób ciągły albo alfabet zawiera jakieś nieprzewidziane przez nas litery, mamy problem. Najlepiej skorzystać z funkcji bibliotecznej :
i f ( i supper (c) )
Powyższy kod dotyczy języków C i C+ + . Odpowiednik dla Javy jest następujący:
i f (Character . i sUpperCase (c) )
Podobny problem dotyczy liczby O, która często występuje w programach w rozmaitych kontekstach. Kompilator przekonwertuje ją sobie na odpowiedni typ, ale gdybyśmy go okre ślali jawnie, to czytający kod miałby znacznie ułatwione zadanie. Na przykład w języku C do
32
1. STYL
reprezentacji wskaźnika zerowego używajmy zapisu (voi d*) O lub NULL, a bajt zerowy na końcu łańcucha znaków oznaczajmy notacją ' \O ' zamiast po prostu O. Innymi słowy, zamiast pisać
str = O ; narne [ i ] = O ; X = O; piszmy
str = NULL; narne [ i ] = ' \O ' ; X = O.O; Wolimy używać innych wyraźnie określonych stałych, a O zarezerwować do reprezentowa nia literalnej wartości zero, ponieważ ten sposób użycia stałych stanowi jakby fragment doku mentacji. Jednak w języku C + + przyjęte jest, że do oznaczania zerowych wskaźników używa się O, a nie słowa NULL. Najlepiej ten problem rozwiązano w Javie - zdefiniowano słowo klu czowe nul 1 służące do tworzenia referencji obiektowych, które do niczego się nie odnoszą.
Rozmiary obiektów określaj za pomocą konstrukcji językowych. Nie określaj bezpośrednio rozmiaru żadnego typu danych, a więc zamiast pisać np. 2 lub 4, pisz s i zeof ( i n t ) . Analogicz nie lepszym rozwiązaniem może być zapis s i zeof (array[O] ) niż s i zeof ( i n t ) , ponieważ jeśli typ tablicy się zmieni, będziemy mieli o jedną rzecz do zmodyfikowania mniej.
Operator s i zeof pozwala czasami uniknąć wymyślania nazw dla liczb określających roz miary tablic. Jeśli np. napiszemy
char buf [1024] ; fgets ( buf, s i zeof {buf) , stdi n ) ; rozmiar bufora pozostaje liczbą magiczną, ale występuje tylko raz, w deklaracji. Wymyśla nie nazwy dla rozmiaru lokalnej tablicy może nie być warte zachodu, lecz bez wątpienia warto pisać kod w taki sposób, aby nie trzeba było w nim nic poprawiać, gdy zmieni się typ lub rozmiar. Tablice w Javie mają pole 1 ength określające liczbę elementów:
char buf[] = new char[1024] ; for ( i nt i = O; i < buf. l engt h ; i ++)
w c i c + + nie ma odpowiednika konstrukcji 1 ength, ale liczbę elementów tablicy (nie wskaźnika), której deklaracja jest widoczna, można obliczyć za pomocą poniższego makra: .
#defi ne N ELEMS (array) ( s i zeof (array) / s i z eof(array [O] ) ) doubl e dbuf [lOO] ; for ( i = O ; i < NELEMS {dbuf) ; i ++)
33
1.6. KOMENTARZE
Rozmiar tablicy został ustawiony tylko w jednym miejscu. Jeśli s i ę zmieni, reszta kodu i tak pozostanie bez zmian. W przypadku tego makra nie występuje problem z wielokrotną ewaluacją, ponieważ nie mogą wystąpić żadne efekty uboczne, a obliczenia są w istocie wyko nywane w czasie kompilacji programu. Jest to dobry przykład zastosowania makra, gdyż robi coś, czego nie da się zrobić za pomocą funkcji - oblicza rozmiar tablicy z jej deklaracji.
Ćwiczenie 1.10. Przepisz poniższe definicje tak, aby zminimalizować ryzyko wystąpienia błędów. #defi ne #defi ne #defi ne #defi ne #defi ne
FT2METER 0 . 3048 METER2FT 3 . 28084 MI2FT 5280 . 0 MI2KM 1 . 609344 SQMI2SQKM 2 . 589988
1 .6. Komentarze Komentarze mają za zadanie pomagać w zrozumieniu kodu programu. To nie znaczy jednak, że należy w nich pisać to, co w sposób oczywisty wynika z kodu, ani też zaprzeczać temu, co widać, tudzież rozpraszać czytającego poprzez stosowanie wyrafinowanych zabiegów typogra ficznych. Idealny komentarz pomaga w zrozumieniu kodu, wyłuszczając najbardziej istotne szczegóły lub w szerszej perspektywie ukazując proces wykonywania.
Nie wypisuj oczywistych rzeczy. Komentarzy nie należy używać do informowania o rzeczach oczywistych, np. że instrukcja i ++ zwiększyła wartość i . Oto kilka naszych ulubionych bezwar tościowych komentarzy:
?
/* * default */
defau l t : break ; /* zwraca SUCCESS */
return SUCCESS ; zerocount++ ; /* Zwiększa licznik zer */ I* lnicjalizuje fota! wartością number_received */
node->total
=
node->number_recei ved ;
Wszystkie te komentarze należałoby usunąć, bo tylko zaśmiecają kod. Komentarz powinien zawiadamiać o czymś, czego nie widać od razu po kodzie, albo gro madzić w jednym miejscu informacje, które są rozproszone w większym obszarze kodu. Ko mentarze mogą być pomocne do objaśniania pewnych subtelnych zjawisk, ale jeśli opisują coś oczywistego, to ich stosowanie jest bezcelowe:
whi l e ( (c = getchar ( ) ) ! = EOF && i sspace (c) ) EOF) i f (c type = endoffi l e ;
/*pomiń białe znaki */ /* koniec plilm */
34
1. STYL
el se i f (c ' (') type = l eftpare n ; el se i f ( c = ' ) ' ) type = ri ghtpare n ; el se i f ( c = ' ; ' ) type = semi col o n ; el se i f ( i s_op (c) ) type = operator; el se if ( i s d i g i t (c ) ) ==
I* otwarcie nawiasu *I I* zamknięcie nawiasu *I I* średnik *I I* operator *I I* liczba *I
Te komentarze również są niepotrzebne, ponieważ wszystko wyjaśniają dobrze dobrane nazwy.
Komentuj funkcje i dane globalne. Oczywiście komentarze mogą być przydatne. Stosujmy je do funkcji, zmiennych globalnych, definicji stałych, pól struktur i klas oraz ogólnie zawsze wtedy, gdy krótkie streszczenie może być pomocne. Zmienne globalne lubią pojawiać się gdzieniegdzie w każdym programie. Opatrzenie ich komentarzem przypomina, do czego służą. Poniżej znajduje się przykład kodu zaczerpnięty z rozdziału 3 . : st ruct State { I *przedrostek i lista przyrostków *I char *pref[NPREF] ; I* przedrostki *I Su ff i x *suf; I* lista przyrostków *I State *n ext ; I* następny element w tablicy mieszającej *I }; Komentarz znajdujący się przed funkcją powinien stanowić krótkie wprowadzenie do jej kodu źródłowego. Jeśli kod ten nie jest bardzo długi ani skomplikowany, wystarczy jedna li nijka komentarza: li random: zwraca liczbę całkowitą z przedziału [O.. r-1]
i nt random ( i nt r) { return ( i nt) (Mat h . fl oor (Math . random ( ) *r) ) ;
Czasami gdy w kodzie zostaną użyte jakieś skomplikowane algorytmy albo struktury da nych, bywa on naprawdę trudny do zrozumienia. Wówczas pomocny może być komentarz odsyłający do właściwego źródła wiedzy. Często też warto objaśnić motywy podjęcia określo nych decyzji. Poniższy komentarz stanowi wprowadzenie do niezwykle wydajnej implementa cji algorytmu odwrotnego dyskretnego przekształcenia kosinusowego (ang. discrete cosine trans form DCT) zastosowanego w dekoderze obrazów JPEG. -
I* * idei: implementacja dwuwymiarowego 8 x8 * algorytmu odwrotnego dyskretnego przekształcenia kosinusowego * Chen-Wanga (IEEE ASSP-32, s. 803 - 816, sierpień 1984) * * 32-bitowa arytmetyka całkowitoliczbowa (współczynniki 8-bitowe) * 11 mnoże1i, 29 operacji dodawania wjednym przekształceniu DCT *
35
1.6. KOMENTARZE * Współczynniki rozszerzone do 12 bitów w celu uzyskania * zgodności z IEEE 1180-1990 *I
stat i c {
void
i dct ( i nt
b [8*8] )
W tym bardzo pomocnym komentarzu zawarto informację o materiale referencyjnym, krótko opisano użyte dane, poinformowano o wydajności algorytmu oraz wskazano, jak i dla czego oryginalny algorytm został zmodyfikowany.
Nie komentuj źle napisanego kodu, lecz go poprawiaj. Komentuj wszystko, co może być niejasne lub mylące, ale jeśli długość komentarza przewyższy długość kodu, jest to znak, że coś z tym kodem jest nie tak. W tym przykładzie mamy długi zagmatwany komentarz i warunkowo kompilowaną instrukcję drukowania przeznaczoną do wykrywania błędów. To wszystko służy do objaśnienia jednej instrukcji: I * Wartość O zmiennej result oznacza znalezienie identycznych elementów, zostanie więc zwrócona prawda (wartość różna od zera). Wpozostałych przypadkach wartość resultjest różna od zera, więc zostanie zwrócony fałsz (zero). */
#i fdef DEBUG pri ntf("*** i sword zwraca ! resul t ffl ush (stdout) ; #end i f
%d\n " , ! resul t ) ;
=
return ( ! resul t ) ; Negacje są zawsze trudne do zrozumienia i najlepiej ich unikać, kiedy tylko się da. Czę ściowo do problemu dokłada się nic niemówiąca nazwa zmiennej res ul t. Gdyby zastosowano bardziej deskryptywną nazwę typu matchfound, komentarz stałby się całkowicie zbędny, a i in strukcja drukująca byłaby bardziej przejrzysta.
# i fdef DEBUG pri ntf ( "*** i sword zwraca matchfound ffl ush (stdout ) ; #end i f
=
%d\ n " , matchfound) ;
return matchfound;
Dbaj o spójność komentarzy z kodem. Większość komentarzy w czasie powstawania jest zgodna z kodem, do którego się one odnoszą. Ale często zdarza się tak, że program ewoluuje, do ko du są wprowadzane poprawki i usuwane z niego usterki, a komentarze pozostają bez zmian. To mogłoby być powodem niespójności, którą zaobserwowaliśmy w pierwszym przykładzie w tym rozdziale. Bez względu na to, jaka była przyczyna powstania rozbieżności między kodem a komentarzami, komentarze, które nie odzwierciedlają tego, co jest w kodzie, zawsze wprowadzają w błąd i już niejednego zmusiły do przeprowadzenia niepotrzebnej sesji wykrywania błędów. Pamiętaj, aby przy każdej zmianie kodu źródłowego sprawdzić, czy komentarze nadal są poprawne.
36
1. STYL
Komentarze powinny nie tylko zgadzać się z kodem, lecz także go wspierać. Komentarz w poniższym przykładzie jest poprawny - objaśnia znaczenie dwóch następnych wierszy kodu - ale wydaje się, że nie odpowiada temu, co rzeczywiście robi kod. Jest w nim mowa o znaku nowego wiersza, a w kodzie - o spacji:
t i me (&now) ; strcpy (date , ctime (&now) ) ;
? /* usuwa końco1'11)1 znak nowego wiersza skopiowany z 1'\l)lniku funkcji ctime */ ? i =O ; ? whi l e (date [i] > = ' ' ) i ++ ; ? date [ i ] = O ; Jedną z możliwości poprawienia tego jest ponowne napisanie kodu w bardziej idiomatyczny sposób:
?
t i me (&now) ; strcpy (date, c t i me (&now) ) ; /* usuwa ko1ico1'11)1 znak nowego wiersza skopiowany z wyniku funkcji ctime */
for ( i = O ; date [ i ] ! = ' \n ' ; i ++)
dat e [ i ] = ' \O ' ; Teraz komentarz zgadza się z kodem, ale jeden i drugi można poprawić, stosując bardziej bezpośrednie wyrażenie. Rozwiązywanym tu problemem jest usunięcie znaku nowego wiersza, który funkcja et i me umieszcza na końcu zwracanego przez siebie łańcucha. Zarówno komentarz, jak i kod powinny to jasno komunikować:
t i me (&now) ; strcpy (date, ctime(&now) ) ; /* Funkcja ctimeO umieszcza na ko1icu łańcucha znak nowego wiersza. Usuwamy go. */
date [strl en (date) - 1 ] = ' \O ' ;
Ostatnie wyrażenie w tym kodzie to idiomatyczny sposób usuwania ostatniego znaku z łań cucha w języku C. W tej postaci kod jest krótki, idiomatyczny i klarowny, a komentarz dobrze go wspiera, objaśniając jego rolę.
Objaśniaj, zamiast zaciemniać. Zadaniem komentarzy jest pomóc czytającemu przebrnąć przez trudniejsze partie kodu, a nie stwarzać dodatkowe problemy. W poniższym przykładzie zastosowano nasze wskazówki dotyczące komentowania funkcji i objaśniania nietypowych fragmentów. Jednakże mowa tu o funkcji strcmp i te nietypowe elementy mają drugorzędne znaczenie dla wykonywanego zadania, którym jest implementacja standardowego i powszech nie znanego interfejsu: ? i nt strcmp (char *s l , char *s2) ? /* Procedura porównująca ła1icuchy, zwracająca wartość -1,jeśli si jest nad */ ? /* s2 w liście posortowanej rosnąco, O, jeśli s I i s2 są równe, */ /* oraz I, jeśli si jest poniżej s2. */
{
?
whi l e (*sl ==*s2) { i f (*sl == ' \O ' ) return (O) ; s l++ ; s2++ ;
1 .6.
KOMENTARZE
37
i f (*sl >*s2) return ( l } ; return ( - 1 ) ;
Jeśli do opisania działania kodu potrzeba więcej niż kilku słów, to najczęściej taki kod na daje się do ponownego napisania. W tym przypadku kod z pewnością można by poprawić, ale większy problem sprawia zbyt długi i niejasny komentarz (co dokładnie oznacza „nad"?). Nie można powiedzieć, że ten kod trudno zrozumieć, ale skoro jest to implementacja funkcji stan dardowej, to jej komentarz mógłby zawierać opis działania i odsyłacz do definicji. Niczego więcej nie potrzeba: I* strcmp: wartość zwrotna < O, jeśli s/ O, jeśli sl>s2; O, jeśli sl =s2 *I I* ANSJ C, podrozdział 4. 11.4.2 *I
i nt strcmp (const char *s l , const char *s2}
Studentom mówi się, że należy wszystko komentować. Także zawodowym programistom często nakazuje się komentować cały pisany przez nich kod. Trzeba jednak pamiętać, że ślepe trzymanie się reguł może całkowicie zatrzeć prawdziwe przeznaczenie komentarzy. Komenta rze mają na celu pomóc czytającemu kod w zrozumieniu tych partii kodu, których znaczenie nie jest oczywiste. Staraj się pisać kod jak najprostszy, wówczas będziesz potrzebować mniej komentarzy. Dobry kod wymaga mniej komentarzy niż słaby.
Ćwiczenie 1.11. Wypowiedz się na temat poniższych komentarzy. voi d d i ct : : i nsert (stri ng& w)
li Zwraca 1, jeśli wjest w słowniku, w przeciwnym razie zwraca O
i f (n
>
MAX 1 1 n % 2 > O} li Sprawdza, czy liczbajestparzysta
li Drukuje komunikat li Zwiększa licznik wierszy po wydrukowaniu każdego wiersza
vo i d wri te-message () {
li Zwiększa licznik wierszy
l i ne number = l i ne number + l ; fpri ntf(fout , "%d %s\n%d %s\n%d %s\n" , l i ne_number , HEADER, l i ne number + l, BODY , l i ne=number + 2 , TRAI LER} ;
li Zwiększa licznik wierszy
l i ne number = l i ne_number + 2 ;
38
I.
STYL
1 .7. Dlaczego warto dbać o styl? W rozdziale tym omówiliśmy najważniejsze kwestie dotyczące stylu programowania : stosowa nie nazw deskryptywnych, klarowność wyrażeń, proste sterowanie wykonywaniem instrukcji, czytelność kodu i komentarzy oraz konieczność spójnego stosowania konwencji i idiomów, aby osiągnąć te cele. Trudno się nie zgodzić z wszystkimi przedstawionymi stwierdzeniami. Ale po co w ogóle dbać o styl? Kogo obchodzi wygląd programu, który dobrze działa? Czy upiększanie kodu nie zajmuje za dużo czasu, a poza tym - czy te wszystkie zasady nie są ustalone arbitralnie? Prawda jest taka, że dobrze napisany kod łatwiej się czyta i lepiej rozumie, prawie zawsze ma mniej błędów i często jest krótszy od niedbale skleconych instrukcji, którym nie poświęco no ani chwili na wyszlifowanie. Kiedy pracujemy pod presją czasu, bardzo łatwo jest odsunąć kwestie stylu na bok, aby tylko zdążyć skończyć pracę w wyznaczonym terminie. Decyzja, aby zająć się stylem później, może nas jednak dużo kosztować. Co może pójść nie tak, jeśli nie za dbamy wystarczająco o styl, widzieliśmy w niektórych przedstawionych w tym rozdziale przy kładach. Niechlujny kod to zły kod - i to nie tylko trudny do odczytania, lecz także często po prostu najeżony usterkami. Kluczowe znaczenie ma to, aby wyrobić sobie nawyk pisania w dobrym stylu. Można to osiągnąć poprzez stosowanie się do wymienionych zasad od samego początku pisania każdego programu i przeglądanie tego, co się napisało, w celu naniesienia poprawek. Gdy już dobry styl wejdzie Ci w krew, wiele szczegółów będziesz podświadomie wykonywać automatycznie, dzięki czemu nawet pod presją będziesz pisać lepszy jakościowo kod.
Lektura uzupełniająca Jak wspomnieliśmy na początku rozdziału, pisanie dobrego kodu ma wiele wspólnego z pisa niem w dobrym stylu po angielsku. Niezmiennie najlepszą krótką pozycją na temat dobrego stylu pisania w tym języku jest książka The Elements of Style Strunka i White' a (Allyn & Bacon). W rozdziale tym zostały wykorzystane wiadomości z książki The Elements of Programming Style Briana Kernighana i P.J. Plaugera (McGraw-Hill, 1 978). Znakomitym źródłem porad na temat programowania jest książka Writing Solid Code Steve'a Maguire'a (Microsoft Press, 1 993). Także w książkach Kod doskonały Steve'a McConnella (Helion, 201 0) i Expert C Pro gramming. Deep C Secrets (Prentice Hall, 1994) Petera van der Lindena można znaleźć warto ściowe wypowiedzi na temat stylu programowania.
2 Algorytmy i struktury danych
Ostatecznie problem można poprawnie rozwiązać tyll
Raymond Fielding, The Technique of Special Effects Cinematography
Nauka o algorytmach i strukturach danych stanowi jeden z filarów informatyki. Jest to dzie dzina nasycona pięknymi technikami i wyrafinowanymi matematycznymi wywodami. Co wię cej, nie jest to tylko pole do popisu i zabawy dla teoretyków - dzięki zastosowaniu dobrego algorytmu lub odpowiedniej struktury danych problem, którego rozwiązanie mogłoby zająć lata, można rozwiązać w kilka sekund. W takich specjalistycznych dziedzinach, jak obróbka grafiki, bazy danych, analiza skła dniowa i przeprowadzanie symulacji zastosowanie najbardziej kunsztownych algorytmów jest warunkiem w ogóle wykonalności niektórych zadań. Jeśli pracujesz nad programem w nowej dla siebie dziedzinie, koniecznie zapoznaj się z aktualnym stanem wiedzy w branży albo zmarnu jesz mnóstwo czasu na wypracowanie marnego rozwiązania problemu, który ktoś już rozwiązał bardzo dobrze. W każdym programie używa się algorytmów i struktur danych, ale bardzo rzadko koniecz ne jest opracowywanie czegoś całkiem nowego. Nawet w takich skomplikowanych aplikacjach, jak kompilatory i przeglądarki internetowe przeważającą część wszystkich uŻytych struktur danych stanowią tablice, listy, drzewa i tablice mieszające. Jeśli w programie występuje coś bardziej złożonego, najczęściej jest to zbudowane na bazie wymienionych podstawowych struktur. Zatem większość programistów powinna przede wszystkim poznać dostępne algorytmy i struk tury danych i nauczyć się wybierania ich do właściwych celów. Nie rozwodząc się zbytecznie, można powiedzieć, że istnieje zbiór kilku podstawowych al gorytmów, które są używane w prawie każdym programie - są to przede wszystkim algorytmy przeszukiwania i sortowania - i nawet z tych wiele można znaleźć w bibliotekach. Analogicz nie prawie wszystkie struktury danych są utworzone na bazie kilku podstawowych struktur. Dlatego materiał przedstawiony w tym rozdziale będzie wyglądał znajomo prawie wszystkim programistom. Aby nie pisać o nierealnych rzeczach, napisaliśmy działające wersje omawia nych przez nas algorytmów i struktur. Można je w całości skopiować i wykorzystać, jeśli zaj dzie taka potrzeba, ale zanim to zrobisz, zapoznaj się z ofertą biblioteki języka programowania, którego używasz.
40
2. ALGORYTMY I
STRUKTURY DANYCH
2. 1 . Przeszukiwanie Tablice są najlepsze do przechowywania danych statycznych nadających się do zapisania w ta beli. Dzięki inicjalizacji w czasie kompilacji tworzenie tablic to mało wymagający i prosty pro ces (w Javie tablice są inicjalizowane w czasie działania programu, ale ten szczegół implemen tacyjny zaczyna mieć znaczenie dopiero wówczas, gdy tablice są bardzo duże). W programie do wykrywania słów, których nie należy raczej używać w pięknym języku literackim, moglibyśmy znaleźć następującą tablicę:
char *fl ab[] = { 11 wi ęc" , 11 bo 11 , " s łabi z na " , " c i en i ut ko " , NULL }; Procedura przeszukująca musi wiedzieć, ile tablica zawiera elementów. Informację tę można jej przekazać w postaci argumentu albo (jak poniżej) umieszczając wartość NULL na końcu struk tury danych: /* lookup: sekwencyjne wyszukiwanie słów w tablicy */
i nt l ookup (char *word , char *array [] )
i nt i ; for (i O ; array [ i ] ! = NULL; i ++) i f (strcmp (word , array [i ] ) == O ) return i ; return - 1 ; =
w językach c i c + + parametry będące tablicami łańcuchów można deklarować jako zmienne typu char *array [] lub char **array. Obie formy są równoważne, ale pierwsza wy raźniej wskazuje planowany sposób użycia parametru. Ten algorytm jest nazywany wyszukiwaniem sekwencyjnym, ponieważ szuka określonego elementu, sprawdzając po kolei wszystkie elementy struktury danych. Przy małej ilości danych algorytm ten działa wystarczająco szybko. Istnieją jego standardowe implementacje służące do przeszukiwania niektórych typów danych, np. funkcje strchr i strstr wyszukują pierwsze wystąpienie danego znaku lub podłańcucha w łańcuchach C i C+ + , klasa Stri ng w Javie ma me todę o nazwie i nd exOf, a ogólnych algorytmów fi nd języka C+ + można używać na prawie wszystkich typach danych. Jeśli dla typu danych, który Cię interesuje, istnieje odpowiednia funkcja, użyj jej. Algorytm przeszukiwania sekwencyjnego można łatwo zaimplementować, ale ilość pracy, którą on wykonuje, jest wprost proporcjonalna do ilości danych, które trzeba przeszukać. Jeśli szukany element nie istnieje w strukturze danych, to podwojenie jej rozmiaru spowoduje po dwojenie czasu przeszukiwania. Ponieważ występuje tu zależność liniowa (czas wykonywania jest funkcją liniową rozmiaru zbioru danych), ta metoda nazywana jest również przeszukiwa
niem liniowym. Oto fragment tablicy o bardziej realistycznym rozmiarze z programu wykonującego analizę składniową kodu HTML. Zdefiniowano w niej nazwy dla ponad stu znaków:
2.1.
PRZESZUKIWANIE
41
typedef struct Nameval Nameval ; struct Nameval { char *name ; i nt val ue; }; /* Znaki HTML, np. AE!igjest ligaturą złożoną z liter A i E. */ /* Wartości są kodowane zgodnie ze standardem Unicode/ISOJ0646. */
Nameval html chars D = { "AEl i g " , Ox00-6 , "Aacute " , Ox00-1 , "Aci rc " , Ox00-2 , /* ... */
"zeta " , Ox03b6 ,
}; Do przeszukiwania większych tablic, jak ta, lepiej użyć algorytmu przeszukiwania binar nego. Zasada jego działania jest podobna do tego, jak szukamy słów w słowniku. Zaczynamy od sprawdzenia elementu znajdującego się w środku. Jeśli wartość środkowa jest większa od szukanej, przeszukujemy pierwszą połowę struktury danych, jeśli jest mniejsza - drugą. Czynności te powtarzamy aż do znalezienia szukanego elementu albo stwierdzenia, że nie wy stępuje on w tym zbiorze danych. Aby możliwe było przeprowadzenie przeszukiwania binarnego, tablica musi być posorto wana, tak jak w przykładzie (zrobienie tego i tak należy do dobrego stylu, a poza tym ludzie też lepiej sobie radzą z przeszukiwaniem posortowanych zbiorów danych), i musi być znana jej długość. W tym przypadku może nam pomóc makro N ELEMS z rozdziału 1 . :
pri ntf ( " Li czba s łów w tabl i cy HTML: %d \ n " , NELEMS (htmlchars) ) ; Implementacja w postaci funkcji algorytmu przeszukiwania binarnego dla tej tablicy mo głaby wyglądać tak: /* lookup: binarne wyszukiwanie nazw w tablicy; zwraca indeks */
i nt l ookup (char *name , Nameval tab [] , i nt ntab) { i nt l ow , h i g h , mi d , cmp; l ow O ; h i gh = ntab - 1 ; whi l e ( l ow <= h i gh) { mi d (l ow + h i gh) / 2 ; cmp strcmp (name , tab [mi d] . name) ; i f (cmp < O) h i gh = mi d - 1 ; el se i f ( cmp > O) l ow = mi d + 1 ; e ls e /* znaleziono szukany element */ return mi d ; =
=
=
return - 1 ;
/* brak szukanego elementu */
42
2. ALGORYTMY I STRUKTURY DANYCH
Tablicę html cha rs możemy teraz przeszukać następująco:
hal f = l ookup ( " frac12 " , html chars , NELEMS (html chars ) ) ; W ten sposób znajdziemy indeks symbolu Vi. Algorytm przeszukiwania binarnego w każdej iteracji eliminuje połowę danych. Zatem liczba kroków jest proporcjonalna do tego, ile razy możemy podzielić n przez 2, zanim zostanie nam tylko jeden element. Pomijając kwestię zaokrąglania, wartość ta wynosi log,n. Gdybyśmy więc mieli do przeszukania tysiąc elementów, algorytm liniowy mógłby wykonać do tysiąca kroków, natomiast binarny - około dziecięciu. Gdyby wartość tę zwiększyć do miliona, algo rytm liniowy mógłby wykonać do miliona kroków, a binarny - najwyżej dwadzieścia. Im wię cej elementów, tym korzyści z używania algorytmu przeszukiwania binarnego są większe. Po przekroczeniu pewnego progu (zależnego od implementacji) przeszukiwanie binarne staje się szybsze od liniowego.
2.2. Sortowanie Algorytm wyszukiwania binarnego można stosować tylko na posortowanych zbiorach danych. Jeśli przewidujesz, że jakiś zbiór danych będzie przeszukiwany wielokrotnie, warto go posor tować od razu, aby móc później przeszukiwać go za pomocą algorytmu przeszukiwania binar nego. Jeśli zbiór danych jest z góry znany, można go posortować już podczas pisania programu i zainicjalizować w czasie kompilacji. W przeciwnym razie sortowanie trzeba wykonać już pod czas działania programu. Jednym z najlepszych wszechstronnych algorytmów sortowania jest tzw. sortowanie szyb kie (ang. quicksort) wynalezione w 1 960 roku przez C.A.R. Hoare'a. Algorytm ten stanowi zna komity przykład tego, jak można uniknąć wykonywania niepotrzebnych obliczeń. Dzieli on elementy tablicy na dwie grupy - małe i duże: Wybierz jeden element z tablicy (oś). Pozostałe elementy podziel na dwie grupy: „elementy małe" - mniejsze od elementu osi i „elementy duże" - większe od elementu osi lub mu równe. Posortuj rekurencyjnie każdą z grup. Po zakończeniu tego procesu tablica będzie posortowana. Tajemnicą szybkości algorytmu jest to, że dzięki naszej wiedzy, iż dany element jest mniejszy od elementu centralnego, nie musimy go porównywać z żadnym elementem z grupy dużych elementów. Ta sama zasada obowiązuje przy porównywaniu dużych elementów z małymi. Ten algorytm jest znacznie szyb szy niż takie metody sortowania, jak sortowanie przez wstawianie i bąbelkowe, które każdy element porównują z wszystkimi pozostałymi. Algorytm sortowania szybkiego jest praktyczny i wydajny. Dzięki temu, że poświęcono mu mnóstwo opracowań, powstało wiele rozmaitych jego wersji. Poniżej przedstawiamy jedną z najprostszych implementacji, ale bez wątpienia nienależącą do najszybszych. Poniższa funkcja qui cksort sortuje tablicę liczb całkowitych: /* quicksort: sortuje elementy v[O}„ v[n-1} w porządku rosnącym */
voi d qui c ksort ( i nt v [] , i nt n ) { i nt i , l as t ;
43
2.2. SORTOWANIE
i f (n <= 1 ) /* Nie ma nic do roboty *I return ; swap ( v , O, rand ( ) % n) ; /* Przesunięcie osi do v[O] */ l ast = O ; for ( i = 1 ; i < n ; i ++) l* Podziałna grupy */ i f ( v [i ] < v [O] ) swap ( v , ++l ast, i ) ; swap ( v , O , l ast) ; /* Przywrócenie osi */ qui c ksort ( v , l as t ) ; /* Sortowanie rekurencyjne */ qui c ksort (v+l ast+l , n - 1 ast - 1 ) ; /* każdej z części */
Ponieważ operacja swap zamieniająca miejscami dwa elementy została użyta trzy razy, naj lepiej będzie, jeśli ją zdefiniujemy w postaci funkcji: /* swap: zamienia miejscami elementy v[i] i vlj] *I
voi d swap ( i nt v [] , i nt i , i nt j ) { i nt temp ; temp = v [i ] ; v [i ] v [j ] ; v [j ] = temp ;
W procesie podziału zbioru na dwie części losowo zostaje wybrany element pełniący funk cję osi, który zostaje tymczasowo przeniesiony na początek. Następnie elementy mniejsze od osi (małe elementy) są przenoszone przed niego (do lokalizacji l ast), a większe - za niego (do lokalizacji i ). Na początku procesu, zaraz po przeniesieniu osi na początek, l ast=O, a elementy tablicy o indeksach od 1 do n - 1 są jeszcze niezbadane: Elementy niebadane
p
t t
t
l ast
n-1
W początkowych iteracjach pętli for elementy od 1 do l as t są mniejsze od osi, elementy od l as t+ 1 do i - 1 są od niej większe lub jej równe, zaś elementy od i do n - 1 nie zostały jeszcze sprawdzone. Dopóki element v [ i ] nie jest większy ani równy v [O] , algorytm może zastępować element v [ i ] nim samym. To powoduje pewną stratę czasu, ale na tyle niewielką, że nie ma się czym przejmować. p
< p
t t o
1
El ementy niebadane
>= p
t
l ast
t
t
n-1
P o podzieleniu wszystkich elementów element O zostaje zamieniony miejscami z elemen tem l a st, aby element osi znalazł się w ostatecznym położeniu. W ten sposób uzyskiwana jest prawidłowa kolejność. Teraz tablica wygląda tak:
44
2. ALGORYTMY I STRUKTURY DANYCH
<
t o
p
p
t
l ast
>= p
t
n-1
T e same działania s ą wykonywane n a lewej i prawej części tablicy. P o ich zakończeniu cała tablica jest posortowana. Jak szybki jest algorytm sortowania szybkiego? W najbardziej optymistycznym przypadku •
w pierwszym przebiegu zbiór n elementów zostaje podzielony na dwie części po n/2 ele mentów;
•
następnie z tych dwóch części tworzone są cztery części, każda po około n/4 elementów;
•
później te cztery części, każda po n/4 elementów, zostają podzielone na osiem części, każda po około n/8 elementów;
•
itd.
Proces ten jest powtarzany log,n razy, a więc w najbardziej optymistycznym przypadku liczba kroków wykonywania algorytmu odpowiada n + 2xn/2 + 4xn/4 + 8xn/8„ . (wyrazy log,n), co jest równe nlog,n. Ś rednia wartość jest tylko nieznacznie wyższa. Zwyczajowo używa się lo garytmów o podstawie 2, dlatego mówi się, że poziom złożoności algorytmu sortowania szyb kiego wynosi nlogn. Mimo iż przedstawiona implementacja algorytmu bardzo dobrze nadaje się do prezentacji ze względu na swoją prostotę, ma ona swoje wady. Jeśli oś za każdym razem dzieli zbiór danych na dwie prawie równe części, to wszystko jest w porządku. Jeśli jednak podział byłby zbyt czę sto nierówny, to poziom złożoności algorytmu mógłby zbliżyć się do n2• Wybierając element osi losowo w naszej implementacji, zmniejszyliśmy ryzyko trafienia na nietypowe wartości, które spowodują nierówny podział danych. Ale gdyby wszystkie wartości były takie same, to nasza implementacja dokonywałaby podziału po jednym elemencie za każdym razem i czas wykonywania wyniósłby n2• Działanie wielu algorytmów w znacznym stopniu zależy od danych wejściowych. Dlatego niektóre algorytmy po otrzymaniu niefortunnego zbioru danych mogą działać bardzo wolno albo zużywać zbyt dużo pamięci. Bardziej finezyjne implementacje algorytmu sortowania szybkiego mogą prawie całkowicie wyeliminować ryzyko takich niepożądanych zachowań.
2.3. Biblioteki W bibliotekach języków C i C+ + znajdują się funkcje sortujące, które są odporne na nieko rzystne dane wejściowe i zoptymalizowane pod kątem szybkości działania. Procedury biblioteczne są zaprojektowane do operowania na wszystkich typach danych, za co ceną jest konieczność dostosowania się do ich nieco bardziej, niż widzieliśmy wcześniej, skomplikowanych interfejsów. W języku C mamy do dyspozycji funkcję o nazwie qsort. Do porównywania wartości wykorzystuje ona wskazaną przez użytkownika funkcję porównującą. Ponieważ wartości mogą być dowolnego typu, funkcja porównująca pobiera dwa wskaźniki typu voi d* na elementy, które mają być porównywane. Sama przekonwertuje je sobie na odpowiedni typ, wydobędzie wartości, porówna je i zwróci wynik (ujemny, zerowy lub dodatni, w zależno ści od tego, czy pierwsza wartość jest mniejsza od drugiej, równa jej czy od niej większa).
45
2.3. BIBLIOTEKI
Poni że j znaj duje si ę i mple me nta cja częst o s pot yka ne j fu nkcji do s ort owa nia ta bli c łań cuchów. F unkcja
s cmp
wykonuje rzut owa nie s woi ch a rg ume nt ów i wywołuje fu nkcję s trcmp w ce lu wy
kona nia porównywa nia .
I* scmp: porównywanie łańcuchów *pl i *p2 *I
i nt scmp (const voi d *pl , const v o i d { char *vl , *v2 ;
*p2)
vl ; * (char **) pl ; v2 ; * (char **) p 2 ; return strcmp ( v l , v2) ;
F unkcję t ę mogli byśmy za pisa ć w je dnym wie rs zu, a le posta nowi li śmy doda ć t ymczas owe zmie nne, a by ułat wi ć czyta nie kodu. Nie może my użyć fu nkcji
strcmp
be zpośre dni o do porównywa nia, ponie wa ż fu nkcja
prze ka zuje a dres ye le me nt ów ta bli cy &str[ i ] (t ypu c har**) za miast i ch wa rt ości
char*),
s t r [i ]
qsort (t ypu
ja k wi da ć na rys unku poni że j:
Tablica n wskaźników: str[ O J st r [ l ] str [ 2 J
str[ N - 1 ]
--+--- ł a ńcuchów
A by pos ort owa ć ele me nt y od s t r [O] do s t r [N - 1] ta bli cy łań cuchów, musi my w wywoła ni u fu nkcji
q sort
prze ka za ć t ęta bli cę, je j dług ość, rozmia r s ort owa nych e le me nt ów ora z fu nk
cję porównującą:
char *str [N] ; qsort (str, N , s i zeof (str [O] ) , scmp) ; A ot o podobna fu nkcja do porównywa nia li czb ca łkowit ych o na zwie
I* icmp: porównuje liczby całkowite *pl i *p2 *I
i nt i cmp ( const voi d *pl , const voi d *p2) { i nt v l , v 2 ; v l ; * ( i nt * ) p l ; v2 ; * ( i nt * ) p2 ;
i cmp:
46
2. ALGORYTMY I STRUKTURY DANYCH
i f { v l < v2) return - 1 ; el se i f ( v l v2) return O; el se return l ; = =
Moglibyśmy napisać
return v l - v 2 ; ale gdyby zmienna v 1 miała dużą wartość dodatnią, a v2 dużą wartość ujemną, lub od wrotnie, mogłoby wystąpić przepełnienie i uzyskalibyśmy nieprawidłowy wynik. Bezpośrednie porównywanie może trwa dłużej, ale jest bezpieczniejsze. W tym przypadku również w wywołaniu funkcji qsort należy przekazać tablicę i jej długość, rozmiar elementów, które mają zostać posortowane, oraz funkcję porównującą: -
i nt arr [N] ; qsort (arr, N , s i zeof (arr [O] ) , i cmp) ;
W języku ANSI C dostępna jest też procedura przeszukiwania binarnego o nazwie bsearch. Podobnie jak qsort wymaga ona wskaźnika na funkcję porównującą (często może to być ta sa ma funkcja, której użyto z funkcją qsort). Zwraca ona wskaźnik na znaleziony element albo NU LL, jeśli nic nie znajdzie. Oto nasza procedura przeszukująca kod HTML dostosowana do funkcji bsearc h : /* lookup: używa funkcji bsearch do znajdowania nazw w tablicy, zwraca indeks */
i nt {
l ookup (char *name , Nameval tab [] , i nt ntab)
Nameval key, * n p ; key . name = name; key. va 1 ue = O; /* Nieużywane, może być cokolwiek */ np = (Nameval *) bsearch (&key, tab , ntab , s i zeof (tab [O] ) , nvcmp) ; i f {np == NULL) return - 1 ; e l se return np-tab ;
Podobnie jak w przypadku funkcji qsort procedura porównująca pobiera adresy elemen tów, które mają zostać porównane, a więc key musi mieć określony typ. W tym przykładzie musieliśmy utworzyć fikcyjny element Nameva l , który przekazujemy do procedury porównują cej. Procedura ta jest funkcją o nazwie nvcmp, porównującą dwa elementy Nameval za pomocą · wywołania funkcji st rcmp na ich składnikach łańcuchowych, przy ignorowaniu ich wartości: /* nvcmp: porównuje dwie naziry Nameval */
i nt nvcmp (const voi d *va , const v o i d *vb) {
2.4. SORTOWANIE SZYBKIE W JAVIE
47
const Nameval * a , * b ; a = (Nameval *) v a ; b = (Nameval *) v b ; return strcmp(a->name, b->name) ;
Funkcja nvcmp jest podobna do funkcji scmp, ale różni się od niej tym, że porównywane łańcuchy są przechowywane jako składowe struktury. Problem z dostarczaniem klucza powoduje, że funkcja bsearch jest mniej wszechstronna od qs ort. Kod dobrej procedury sortującej ogólnego przeznaczenia zajmuje przynajmniej stro nę lub dwie, natomiast kod algorytmu przeszukiwania binarnego jest tylko nieznacznie dłuż szy od kodu potrzebnego do połączenia się z funkcją bsearch. Niemniej jednak i tak lepiej jest korzystać z funkcji bsearch, zamiast pisać własną. Lata doświadczeń podpowiadają, że po prawna implementacja przeszukiwania binarnego jest trudniejsza, niż może się wydawać. W bibliotece standardowej języka C+ + znajduje się ogólny algorytm o nazwie sort o gwa rantowanym czasie wykonywania rzędu O(nlogn) . Korzystanie z niego jest łatwiejsze, ponieważ nie ma potrzeby wykonywania rzutowania elementów ani określania ich rozmiaru oraz nie trzeba bezpośrednio określać funkcji porównującej, jeśli porównywane są typy będące w relacji porządku.
i nt arr [N] ; sort (arr, arr+N) ; Biblioteka języka C+ + zawiera także ogólne procedury przeszukiwania binarnego o po dobnych zaletach.
Ćwiczenie 2.1. Najbardziej naturalnym sposobem wyrażenia algorytmu sortowania szybkiego jest zastosowanie rekurencji. Zaimplementuj go przy użyciu iteracji i porównaj obie wersje (Hoare opisuje, jak trudno mu było zaimplementować ten algorytm przy użyciu iteracji i jak ładnie wszystko poszło, gdy zastosował rekurencję).
2.4. Sortowanie szybkie w Javie W Javie sytuacja wygląda inaczej. W kilku pierwszych wersjach języka nie było standardowej funkcji sortującej, a więc za każdym razem trzeba było pisać własną. W nowszych wersjach jest już funkcja o nazwie sort, która działa na klasach implementujących interfejs Comparab l e. Ponieważ jednak techniki implementacji algorytmu sortowania szybkiego w Javie mogą być przydatne także w innych sytuacjach, postanowiliśmy pokazać, jak się to robi. Napisanie algorytmu sortowania szybkiego dla dowolnie wybranego typu danych, które chcemy posortować, jest nietrudne, ale o wiele więcej się nauczymy, gdy napiszemy ogólny al gorytm sortowania, odpowiedni do zastosowania dla każdego rodzaju obiektów - coś w ro dzaju interfejsu qsort. Jedną z największych różnic między językami C i C + + a Javą jest to, że w Javie nie można w wywołaniu funkcji przekazać funkcji porównującej. W tym języku nie ma wskaźników na funkcje. Zamiast tego tworzy się interfejs, którego jedyną zawartość stanowi funkcja porów nująca dwa obiekty typu Obj ect. Następnie dla każdego typu danych, który chcemy posortować,
48
2. ALGORYTMY I STRUKTURY DANYCH
tworzymy klasę z funkcją składową implementującą interfejs dla tego typu danych. Egzem plarz tej klasy przekazujemy funkcji sortującej, która z kolei porównuje elementy przy użyciu pochodzącej z tej klasy funkcji służącej do porównywania. Zaczniemy od zdefiniowania interfejsu o nazwie Cmp z jedną składową - funkcją cmp do porównywania dwóch obiektów typu Obj ect:
i nterface Cmp { i nt cmp (Obj ect x , Object y) ;
Teraz możemy napisać funkcje porównujące, które implementują ten interfejs. Poniższa przykładowa klasa zawiera definicję funkcji porównującej obiekty typu Integer:
li/cmp: Porównywanie obiektów typu lnteger
c l as s I cmp i mpl ements Cmp { publ i c i nt cmp (Obj ect o l , Obj ect o2) { i nt i l ; ( ( I nteger) o l ) . i ntVal ue() ; i nt i 2 ; ( ( Integer) o2) . i ntVal ue ( ) ; i f (i l < i2) return - 1 ; el se i f ( i l ;; i 2) return O ; else return 1 ;
a ta porównuje obiekty typu Stri ng:
li Scmp: Porównywanie łańcuchów
c l ass Scmp i mpl ements Cmp { publ i c i nt cmp (Obj ect o l , Object o2) { Stri ng s l ; (String) o l ; Stri ng s 2 ; (Stri ng) o2 ; return s l . compareTo (s2) ;
W ten sposób można sortować tylko obiekty typów pochodnych typu Obj ect, a więc wy kluczone są typy podstawowe, takie jak i nt czy do ub 1 e. Dlatego właśnie sortowaliśmy obiekty typu I nteger, a nie wartości typu i nt. Teraz możemy przekonwertować naszą funkcję sortowania szybkiego w języku C na Javę i zmusić ją do wywoływania funkcji porównującej z obiektu Cmp przekazanego jako argument. Najważniejsza zmiana dotyczy użycia indeksów 1 eft i r i ght i została wymuszona przez fakt, że w Javie nie ma wskaźników na elementy tablic. li Quicksort.sort: sortowanie szybkie v[left}.. v[right]
stati c voi d sort {Obj ect [] v , i nt l eft , i nt ri ght , Cmp cmp) { i nt i , l as t ; i f (1 eft > ; ri ght) li nie m a nic do roboty
2.4. SORTOWANIE SZYBKIE W JAVIE
49
return ; swap ( v , l eft , rand (l eft , right ) ) ; liprzesunięcie osi // do v[left} l ast = l eft; for ( i = l eft+l ; i <= r i g h t ; i ++) //podział i f ( cmp . cmp ( v [i ] , v [l eft] ) < O) swap ( v , ++l ast , i ) ; liprzywraca oś swap ( v , l eft , l ast) ; li sortowanie rekurencyjne sort ( v , l eft , l ast-1 , cmp) ; li każdej części sort { v , l ast+l , right, cmp) ;
Metoda Qui cksort . sort porównuje pary obiektów przy użyciu funkcji cmp oraz wywołuje funkcję swap, jak poprzednio, w celu zamienienia tych obiektów miejscami.
li Quicksort.swap: zamienia miejscami obiekty v[i} i v[j} stat i c voi d swap (Obj ect [] v , i nt i , i nt j ) { Obj ect temp; temp = v [i ] ; v [i ] v [j ] ; v [j ] = temp ;
Liczby losowe są generowane przez funkcję losującą wartości z zamkniętego przedziału
l eft-ri ght: stat i c Random rgen = new Random { ) ;
li Quicksort.rand: zwraca losową liczbę całkowitą z przedziału [left, right} stat i c i nt rand ( i nt l eft , i nt ri ght) { return l eft + Math . abs (rge n . nextint ( ) ) %(ri ght-l eft+l ) ;
Za pomocą metody Math . abs obliczamy wartość bezwzględną, której następnie używamy, ponieważ generator liczb losowych Javy może zwracać zarówno ujemne, jak i dodatnie wartości. Funkcje sort, swap i rand oraz obiekt generatora rgen są składowymi klasy Qui cksort. Aby posortować tablicę obiektów typu Stri ng za pomocą metody Qui c ksort . sort, powin niśmy napisać
Stri ng [] sarr = new Stri ng [n] ;
li zapisuje n elementów z tablicy sarr„. Qui c ksort . sort (sarr, O, sarr . l ength - 1 , new Scmp ( ) ) ; Wywołujemy metodę sort, przekazując jej jako argument obiekt porównywania łańcuchów utworzony specjalnie na tę okazję.
Ćwiczenie 2.2. Nasz algorytm wykonuje bardzo dużo konwersji, ponieważ musi rzutować ele menty z ich oryginalnego typu (np. I nteger) na Obj ect i z powrotem. Zoptymalizuj metodę Qui c ksort . sort pod kątem różnych typów danych, aby oszacować, jak duże straty wydajności powodują te konwersje.
50
2. ALGORYTMY I STRUKTURY DANYCH
2.5. Notacja O I lość pracy, jaką musi wykonać algoryt m, wyrażaliśmy w odniesieniu do liczby n elementó w wejściowych. P rzeszukiwanie nieposort owanego zbioru n elementó w może zająć ilość czasu proporcjonalną do n. Czas działania algoryt mu przeszukiwania binarnego na posort owanym zbiorze danych jest proporcjonalny dologn. Czas sort owania może być proporcjonalny do liczby
n2 lubnlogn.
P ot rzebny jest nam jakiś bardziej precyzyjny sposó b wyrażania t ych informacji, któ ry przy okazji pozwoli wyeliminować z rozw ażań t akie czynniki, jak prędkość procesora czy sprawność kompilat ora ( i programist y). I nt eresuje nas poró wny wanie czasu działania i kwest ia wymagań pa mięciowych algorytmó w bez względu na język programowania, kompilat or, archit ekt urę sprzęt ową, szybkość procesora, obciąż enie syst emu i inne czyn niki zaciemniające rzeczy wist y obraz. Do t ego celu służy st andardowa not acja zwana notacją
O ( ang. 0-notation). Jej podst awo n, oznaczająca rozmiar problemu, a złożoność obliczeniową, czyli czas działania algoryt mu, wyraża się jakofu nkcję liczby n. Wielkie O w nazwie t ej not acji
wym paramet rem jest wart ość
oznacza rząd wielkości- jeśli np. złożoność obliczeniowa przeszukiwania binarnego wynosi
O(logn), znaczy t o, że do przeszukania t ablicy zawierającej n elementó w pot rzebnych jest logn krokó w. Zapis O(j(n)) oznacza, iż przy dużych wart ościach n . czas działania algoryt mu będzie 2 najwyżej ró wnyf(n), np. O(n ) lub O(nlogn) . Takie asympt ot yczne szacunki są bardzo pomocne wt eoret ycznej analizie algoryt mó w i bardzo pomagają poró wnywać wiele ró żnych algoryt mó w, ale w prakt yce duże znaczenie mogą mieć pewne szczegó lne przypadki. N a przykład algoryt m
2) może działać szybciej niż algoryt m o wysokim poziomie O(nlogn) przy małych wart ościach n, ale po przekroczeniu pewnego progu t ej wart ości szybszy o niskim poziomie złożoności O(n
może st ać się algoryt m o wolniejszym przyroście poziomu złożoności. M usimy t akże rozró żnić pesymistyczną złożoność
algorytmu
od jego oczekiwanej złożo
ności. N ie da się jednoznacznie określić, czym jest oczekiwana złożoność algoryt mu, ponieważ ma na nią wpływ rozważany rodzaj danych wejściowych. Zwykle da się nat omiast precyzyjnie zdefi niować złożoność pesymist yczną, chociaż może t o być czasami mylące. Złożoność pesymi st yczna algoryt mu szybkiego sort owania wynosiO(n
2), ale spodziewany czas jego wykonywania
określa się na poziomie O(nlogn). St arannie dobierając oś za każdym razem, możemy zmniej szyć prawdopodobieńst wo wyst ąpienia przypadku złożoności kwadrat owej,
O(n 2), w ist ocie do
zera. W prakt yce ty powy dobrze napisany algoryt m sort owania szybkiego ma złożoność obli czeniową rzędu O(nlogn) . P oniższat abela przedst awia wykaz najważniejszych rodzajó w złożoności obliczeniowej: Przykład
Notacja
Nazwa
0(1)
stała
indeks tablicy
O(logn)
logarytmiczna
przeszukiwanie binarne
O(n)
liniowa
porównywanie łańcuchów
O(nlog11)
11/ogn
sortowanie szybkie
O(n2)
kwadratowa
proste algorytmy sortowania
O(n3)
sześcienna
mnożenie macierzy
0(2")
wykładnicza
dzielenie zbiorów
2.6. TABLICE ROZSZERZALNE
51
Czas dostępu do elementów w tablicach to wielkość stała wyrażana jako 0(1). Algorytm, który w każdym przebiegu eliminuje połowę danych wejściowych, np. przeszukiwanie binarne, zwykle ma złożoność obliczeniową rzędu O(logn) . Porównanie dwóch łańcuchów po n znaków za pomocą funkcji strcmp zajmuje O(n) czasu. Typowy algorytm mnożenia macierzy ma zło żoność obliczeniową O(n 3), ponieważ każdy element wynikowy stanowi sumę iloczynów n par liczb, a w każdej macierzy jest n 2 elementów. Wykładnicza złożoność obliczeniowa algorytmów najczęściej bierze się z ewaluacji wszystkich możliwości - w zbiorze n elementów jest 2° podzbiorów, a więc algorytm, który musi przej rzeć wszystkie podzbiory, będzie miał złożoność obliczeniową rzędu 0(2°). Algorytmy o złożono ści wykładniczej zwykle nie nadają się do praktycznych zastosowań, z wyjątkiem przypadków, gdy wartość n jest bardzo mała, ponieważ dodanie jednego elementu powoduje podwojenie cza su wykonywania. Niestety, algorytmów o złożoności wykładniczej jest wiele, np. algorytm roz wiązujący słynny problem komiwojażera. W takich przypadkach najczęściej zadowalamy się algorytmami, które potrafią znaleźć tylko przybliżenie szukanej wartości.
Ćwiczenie 2.3. Wymień kilka zbiorów danych, które spowodowałyby pesymistyczną złożoność obliczeniową algorytmu sortowania szybkiego. Spróbuj znaleźć takie zbiory, które zmusiłyby do powolnego działania wersję algorytmu z biblioteki używanego przez Ciebie języka. Zauto matyzuj cały proces, aby móc łatwo i szybko wykonywać dużą liczbę testów.
Ćwiczenie 2.4. Zaprojektuj i zaimplementuj algorytm sortujący tablicę n liczb całkowitych najwolniej, jak to możliwe. Nie oszukuj, tzn. algorytm musi cały czas robić postępy i w końcu posortować dane. Nie można stosować sztuczek w rodzaju jałowych pętli. Jaka jest złożoność obliczeniowa Twojego algorytmu wyrażona jako funkcja n?
2.6. Tablice rozszerzalne Użyte we wcześniejszych podrozdziałach tablice były statyczne pod tym względem, że ich rozmiar i zawartość były ustalane już na etapie kompilacji programu. Gdybyśmy przewidywali zmiany w tablicach niepożądanych słów albo znaków HTML, lepszym rozwiązaniem byłoby użycie do ich przechowywania tablic mieszających. Złożoność obliczeniowa operacji powięk szania posortowanej tablicy o n elementów jednocześnie wynosi O(n2), a więc należy tego uni kać przy dużych wartościach n . Ponieważ często musimy mieć możliwość przechowywania zmiennych, ale niewielkich zbiorów danych, tablice mogą być dla nas bardzo dobrym rozwiązaniem. W celu zminimalizo wania kosztów alokacji rozmiar tablicy należy zmieniać po kawałku, a dla zachowania porząd ku samą tablicę najlepiej jest trzymać z informacjami potrzebnymi do zarządzania nią. W języ kach c+ + i Java użylibyśmy do tego klas z bibliotek standardowych. w języku c podobny efekt można uzyskać przy użyciu struktur. Poniżej znajduje się definicja rozszerzalnej tablicy elementów typu Nameva l , w której nowe elementy dodawane są na końcu. Czas dostępu do każdego elementu jest stały i dostęp odbywa się za pomocą indeksu. Jest to struktura podobna do klas wektorów w bibliotekach języków Java i c+ + .
typedef struct Nameval Nameval ; struct Nameval { *name ; char val ue; i nt
52
2. ALGORYTMY I STRUKTURY DANYCH
};
struct NVtab { i nt nval ; max ; i nt Nameval tnameval ; nvtab ;
I* aktualna liczba wartości */ /* liczba alokowanych wartości */ I* tablica par nazwa-wartość */
enum { NV I N I T = 1 , NVGROW = 2 } ; /* addname: dodaje nową nazwę i wartość do nvtab *I
i nt addname (Nameval newname) { Nameval *nvp; if (nvtab . nameval == NULL) { /*pierwszy raz */ nvtab . nameval (Nameval *) mal l oc (NV I N I T * s i zeof(Nameval ) ) ; i f (nvtab . nameval == NULL) return 1 ; nvtab . max = NVINI T ; nvtab . nval = O ; e l s e i f (nvtab . nval > = nvtab . max) { /*powiększenie */ nvp = (Nameval *) real l oc (nvtab . nameval , (NVGROW*nvtab .max) * s i zeof(Nameval ) ) ; i f (nvp == NULL) return - 1 ; nvtab . max * = NVGROW; nvtab . nameval nvp; -
=
nvtab . nameval [nvtab . nv a l ] return nvtab . nval ++ ;
newname;
Funkcja addname zwraca indeks ostatnio dodanego elementu lub -1, jeśli wystąpi jakiś błąd. Funkcja rea 1 1 oc rozszerza tablicę, zachowując jej dotychczasowe elementy, i zwraca wskaźnik na tę tablicę lub wartość NULL, jeśli jest za mało pamięci. Dzięki podwajaniu roz miaru w każdym wywołaniu funkcji rea 1 1 oc utrzymuje się spodziewany koszt kopiowania każdego elementu na stałym poziomie. Gdyby rozmiar był w każdym wywołaniu tej funkcji zwiększany tylko o jeden element, złożoność obliczeniowa wyniosłaby O(n2) . Ponieważ adres tablicy po realokacji może się zmienić, w pozostałej części programu musimy odwoływać się do jej elementów za pomocą indeksów, a nie wskaźników. Zwróćmy uwagę, że nie mamy nigdzie takiego kodu:
nvtab . nameval = (Nameval *) real l oc (nvtab . nameval , (NVGROW*nvtab . max) * si zeof(Nameval ) ) ; Przy takim zapisie, gdyby realokacja nie powiodła się, oryginalna tablica zostałaby utracona. Wartość początkową tablicy ustawiamy na bardzo niskim poziomie (NV I N I T = 1). W ten sposób zmuszamy program do zwiększania tablic od samego początku, a więc mamy pewność, że ta jego część zostanie od razu sprawdzona. Po przeznaczeniu programu do praktycznego użytkowania wartość tę można będzie zwiększyć, aczkolwiek koszt rozpoczynania od bardzo małego rozmiaru jest zaniedbywalnie niski.
2.6. TABLICE ROZSZERZALNE
53
Wartości zwrotnej funkcji rea 1 1 o c nie trzeba rzutować na typ ostateczny, ponieważ w ję zyku C promocja typu v o i d* odbywa się automatycznie. Nie robi tego jednak język C+ + , w którym t o rzutowanie jest konieczne. Można się sprzeczać, czy lepiej rzutować (czystość, uczciwość), czy nie rzutować (rzutowanie może ukrywać jakieś błędy). Zdecydowaliśmy się na rzutowanie dlatego, że dzięki niemu program jest poprawny zarówno według standardu języka C, jak i C+ + . Ceną za to jest zmniejszona czujność kompilatora języka C, ale nadrabiamy to do datkową możliwością przeprowadzenia testów przy użyciu dwóch kompilatorów. Problemy może sprawiać usuwanie nazw, ponieważ musimy coś zrobić z powstałą po nich luką w tablicy. Jeśli porządek elementów jest nieważny, lukę można wypełnić ostatnim ele mentem tablicy. W przeciwnym razie konieczne jest przeniesienie wszystkich elementów znaj dujących się za elementem usuniętym o jedną pozycję: I* delname: usuwa pie1wszy znaleziony element nameval z tablicy nvtab *I
i nt del narne (char *narne) { i nt i ; for ( i = O ; i < nvtab . nval ; i ++) i f (strcrnp ( nvtab . n arneval [i] . narne, narne) == O ) { rnernrnove (nvtab . narneval +i , nvtab . narneval +i+l , (nvtab . nval - ( i+l ) ) * s i zeof (Narneval ) ) ; nvtab . nval - - ; return 1 ; return O ;
Funkcja memmove zmniejsza tablicę, przesuwając jej elementy o jedną pozycję. Jest to stan dardowa funkcja do kopiowania bloków pamięci o dowolnym rozmiarze. W standardzie ANSI C można znaleźć definicje dwóch funkcji tego typu: memcopy, która jest szybka, ale może nadpisać pamięć, jeśli źródło i cel na siebie nachodzą, i memmove, która może działać wolniej, ale zawsze bezbłędnie. Programista nigdy nie powinien być stawiany przed dylematem wyboru między szybkością a poprawnością działania kodu. Powinna być tylko jedna funkcja. Dlatego udawajmy, że tak jest, i zawsze używajmy funkcji memmove. Wywołanie funkcji memmove można zastąpić poniższą pętlą:
i nt j ; for (j i ; j < nvtab . nval - 1 ; j ++) nvtab . narneval [j] = nvtab . narneval [j+l] ; Preferujemy jednak funkcję memmove, ponieważ chroni nas przed łatwym do popełnienia błędem skopiowania elementów w niewłaściwej kolejności. Gdybyśmy wstawiali elementy, a nie je usuwali, to w pętli musielibyśmy odliczać w dół zamiast w górę, aby uniknąć nadpisania ele mentów. Używając funkcji memmove, nie musimy się nad tym ciągle zastanawiać. Alternatywnym rozwiązaniem do przesuwania elementów jest oznaczanie usuniętych ele mentów jako nieużywanych. Wówczas przy dodawaniu nowego elementu najpierw szukaliby śmy nieużywanego miejsca i dopiero gdybyśmy takiego nie znaleźli, zwiększalibyśmy wektor. Nieużywany element można by było oznaczyć poprzez przypisanie mu wartości NULL. Tablice są najprostszą s trukturą danych. Nie jest dziełem przypadku to, że w większości ję zyków programowania są wydajne i wygodne w użyciu indeksowane tablice, ani to, że czasami łańcuchy reprezentuje się jako tablice znaków. Tablice są łatwe w użyciu, oferują czas dostępu rzędu 0(1) do każdego elementu, dobrze współpracują z algorytmami przeszukiwania binarne-
54
2 . ALGORYTMY I STRUKTURY DANYCH
go i sortowania szybkiego oraz zużywają niewiele pamięci. W przechowywaniu zbiorów danych o stałym rozmiarze, które można utworzyć już na etapie kompilacji i małych kolekcji danych, tablice są wręcz nie do pobicia. Gorzej sprawa wygląda w przypadku zbiorów danych o zmien nym rozmiarze. Dlatego do przechowywania nieprzewidywalnych i potencjalnie dużych zbio rów danych lepiej używać innych struktur.
Ćwiczenie 2.5. W powyższym kodzie funkcja del name nie wywołuje funkcji rea 1 1 oc w celu zwrócenia pamięci zwolnionej w wyniku usunięcia elementu. Czy warto się tym przejmować? Na jakiej podstawie należy podjąć decyzję, czy to ma znaczenie?
Ćwiczenie 2.6. Zmodyfikuj funkcje addname i del n ame, aby zamiast usuwać elementy, ozna czały je jako nieużywane. Jak bardzo reszta programu jest niezależna od tych zmian?
2.7. Listy Drugą po tablicach pod względem popularności w typowych programach strukturą danych są listy. W wielu językach można znaleźć standardowe implementacje tych struktur, a niektóre np. LISP - całkowicie się na nich opierają. To nie zmienia faktu, że jeśli w języku C chcemy użyć listy, musimy ją sobie zbudować sami. w c + + i Javie listy są dostępne w bibliotekach, ale też musimy nauczyć się z nich korzystać. W tej części rozdziału skoncentrujemy się na bu dowie list w języku C, ale zawarte tu wskazówki mają dalece szerszy sens.
Listy jednokierunkowe to zbiory elementów, z których każdy zawiera jakieś dane i wskaźnik na następny element. Przed pierwszym elementem listy znajduje się element zwany głową (ang. head). Zawiera on wskaźnik na pierwszy element. Natomiast na końcu listy znajduje się element, który obejmuje wskaźnik pusty. Poniższy rysunek przedstawia listę złożoną z czte rech elementów: G owa
-
NULL Element 1
Element 2
Element 3
Element 4
Tablice i listy różnią się między sobą w kilku kluczowych kwestiach. Po pierwsze tablice mają stały rozmiar, natomiast lista zawsze ma taki rozmiar, jaki jest potrzebny do pomieszcze nia wymaganych elementów, powiększony o miejsce do przechowywania wskaźników. Po dru gie elementy w liście można przemieszać, zmieniając tylko kilka wskaźników, co wymaga znacznie mniej pracy niż przenoszenie bloków pamięci w tablicach. W końcu dodawanie do listy elementów i usuwanie ich z niej nie wymaga przesuwania pozostałych elementów. Jeśli wskaźniki na elementy zostaną zapisane w osobnej strukturze danych, to żadne zmiany w liście nie będą mogły spowodować ich uszkodzenia. Te różnice wskazują, że do przechowywania zmiennych, a zwłaszcza nieprzewidywalnych zbiorów danych lepiej nadają się listy. Tablice są natomiast lepsze do przechowywania danych statycznych. Istnieje zbiór podstawowych operacji, które można wykonać na każdej liście - dodawanie elementu na początku i końcu, wyszukiwanie określonego elementu, dodawanie i usuwanie elementu znajdującego się przed lub za określonym elementem oraz usuwanie wybranego ele mentu. Listy są jednak na tyle prostymi strukturami, że ich zestaw operacji można w razie po trzeby bez trudu rozszerzyć.
2.7. LISTY
55
W języku C, aby skorzystać z listy, nie pisze się bezpośredniej definicji typu L i st, lecz po prostu definiuje się typ elementów, jak np. typ Nameval , o którym już była mowa, i wzbogaca się go o wskaźnik na następny element:
typedef struct Nameval Nameval ; struct Nameval { char *name ; i nt val u e ; Nameva l *n ext ; /* następny element listy */
};
Ponieważ trudno jest zainicjalizować pustą listę w czasie kompilacji, listy w przeciwień stwie do tablic są tworzone dynamicznie. Najpierw musimy znaleźć sposób na tworzenie ele mentów. Najprościej będzie zastosować alokację przy użyciu odpowiedniej funkcji, którą na zwiemy newi tern: I* newitem: tworzy nowy element z naZłl'.)' i wartości *I
Nameval *newi tem ( char *name, i nt val ue) { Nameval *newp ; newp ; (Nameval *) emal l oc (s i zeof(Nameval ) ) ; newp->name ; name ; newp->val ue ; val ue; newp->next ; NULL; return newp;
Procedury ema 1 1 oc będziemy używać jeszcze wiele razy, więc warto się z nią bliżej zapo znać. Wywołuje ona funkcję ma 1 1 oc i jeśli alokacja się nie powiedzie, zgłasza błąd, po czym zamyka program. Jej kod zobaczymy w rozdziale 4. Na razie wystarczy nam wiedza, że będzie my ją traktować jako alokator pamięci, który nigdy nie zwraca informacji o niepowodzeniu. Najprostszym i najszybszym sposobem tworzenia listy jest dodawanie nowych elementów na początku: /* addfront: dodaje newp na początku listp */
Nameval *addfront(Nameval *l i st p , Nameval *newp) { newp->next ; l i st p ; return newp ;
Jeśli lista zostanie zmodyfikowana, jej pierwszy element może zostać zmieniony - tak się dzieje, gdy wywołamy jej funkcję addfront. Dlatego funkcje zmieniające listę muszą zwracać wskaźnik na jej nowy pierwszy element, który jest zapisywany w zmiennej oznaczającej listę. Funkcja addfront i podobne do niej zwracają ten wskaźnik jako swoją wartość zwrotną. Oto typowy sposób użycia tej funkcji:
nv l i st ; addfront ( nv 1 i st, new i tem ( 11 smi l ey 11 , Ox263A) ) ; Tak zaprojektowanej funkcji można używać nawet wówczas, gdy lista jest pusta, a ponadto nie sprawia ona problemów przy łączeniu z innymi funkcjami w wyrażeniach. To podejście wydaje się bardziej naturalne niż przekazywanie wskaźnika na wskaźnik głowy listy.
56
2. ALGORYTMY I STRUKTURY DANYCH
Złożoność obliczeniowa operacji dodawania elementu na końcu listy wynosi O(n), ponie waż aby znaleźć koniec, musimy przejrzeć wszystkie elementy listy: I* addend: dodaje newp na ko1icu listy listp */
Nameval *addend (Nameval *l i stp, Nameval *newp) { Nameval *p; if ( l i stp NULL) return newp ; for (p = l i stp ; p->next ! = NULL ; p = p->next) ==
p->next newp ; return l i stp ; =
Gdybyśmy chcieli obniżyć złożoność obliczeniową funkcji addend do 0(1), moglibyśmy osobno zapisać wskaźnik na ostatni element listy. Wadą tej metody, pomijając kłopoty z utrzymaniem wskaźnika na ostatni element, jest to, że nasza lista nie byłaby już reprezentowana tylko przez jedną zmienną wskaźnikową. Dlatego pozostaniemy przy uproszczonej wersji. Aby znaleźć element o określonej nazwie, listę należy przeglądać przy użyciu wskaźnika
next : /* lookup: sekwencyjne wyszukiwanie nazw w listp */
Nameval *l ookup(Nameval *l i st p , char *name) { for ( ; l i stp ! = NULL; l i stp = l i stp->next) i f (strcmp (name, l i stp->name) == O) return l i stp ; return NULL; /* nie znaleziono */
Ta operacja ma złożoność obliczeniową rzędu O(n) i w zasadzie nie da się tego wyniku po prawić. Nawet gdyby lista była posortowana, i tak musielibyśmy ją przejrzeć liniowo, aby do trzeć do określonego elementu. W listach nie da się zastosować przeszukiwania binarnego. Drukowanie elementów listy można zaimplementować w postaci funkcji odwiedzającej i dru kującej po kolei wszystkie elementy. Długość listy można obliczyć za pomocą funkcji przechodzą cej kolejno od elementu do elementu i za każdym razem zwiększającej licznik. Alternatywnie można napisać jedną funkcję, np. o nazwie appl y, która będzie przeglądać listę i dla każdego jej elementu wywoływać jakąś inną funkcję. Funkcja ta będzie jeszcze bardziej przydatna, jeśli umożliwimy przekazanie w niej argumentu funkcji, którą ma wywoływać. Zatem nasza funkcja app l y będzie miała trzy argumenty - lista, funkcja do wywołania na rzecz każdego elementu oraz argument dla tej funkcji: /* apply: wykonujefn dla każdego elementu listp */
voi d app l y (Nameval *l i stp , v o i d {*fn) (Nameval * , voi d*) , v o i d *arg) for ( ; l i stp ! = NULL; l i stp = l i stp->next) {*fn) { l i st p , arg) ; /* wywolaniefankcji */
57
2.7. LISTY
Drugim argumentem funkcji app l y jest wskaźnik na funkcję, która pobiera dwa argumenty i nie zwraca wyniku. Standardowy, choć mało elegancki zapis
voi d (*fn) (Nameval * , voi d*) jest deklaracją fn jako wskaźnika na funkcję voi d, tzn. fn jest zmienną przechowującą ad res funkcji, która nie zwraca wyniku. Pobiera ona dwa argumenty: Nameva 1 * (element listy) i voi d * (ogólny wskaźnik na argument tej funkcji). Aby za pomocą funkcji app 1 y np. wydrukować elementy listy, możemy napisać prostą funkcję pobierającą jako argument łańcuch formatujący: /*printnv: drukuje nazwy i wartości przy użyciuformatu zapisanego w arg */
v o i d pri ntnv (Nameval *p, voi d *arg) { char *fmt ; fmt = (char *} arg ; pri ntf ( fmt , p->name, p->val ue) ;
Oto sposób jej wywołania:
appl y (nvl i st , pri ntnv, "%s : %x\n " ) ; Do liczenia elementów zdefiniujemy funkcję przyjmującą jako argument wskaźnik na liczbę całkowitą, którą będziemy odpowiednio zwiększać: /* inccozmter: zwiększa licznik *arg */
voi d i nccounter (Nameva 1 *p , voi d *arg) i nt * i p ; /* pjest nieużywany */
i p = ( i nt *) arg ; (*i p ) ++ ;
Sposób wywołania:
i nt n ; n = O; appl y ( nvl i st , i nccounter, &n) ; pri ntf ( " Li czba el ement6w w l i lc i e nvl i st : %d \n " , n ) ; Ten sposób nie jest najlepszy do wykonywania wszystkich operacji listowych. Przykładowo przy usuwaniu listy powinniśmy zachować większą ostrożność: /* ji-eeall: zwalnia wszystkie elementy listp */
v o i d freeal 1 (Nameval * 1 i stp) { Nameval *next ; for ( ; l i stp ! = NULL; l i stp next = l i stp->next ;
next) {
58
2. ALGORYTMY I STRUKTURY DANYCH
/* założenie, że name usunięto gdzieś indziej *I
free ( 1 i stp) ;
Ponieważ po zwolnieniu pamięci nie można jej używać, przed zwolnieniem elementu wskazy wanego przez wskaźnik l i stp musimy l i stp->next zapisać w zmiennej lokalnej o nazwie next. Gdyby pętla została napisana tak jak inne
for ( ; l i stp ! = NULL ; l i stp = l i stp->next) free ( l i stp} ; wartość l i stp->next mogłaby zostać nadpisana przez free i wystąpiłby błąd. Zauważmy, że funkcja freea 1 1 nie zwalnia pamięci pola l i s tp->narne. Przyjęliśmy w niej założenie, że pole narne każdego elementu Narneva l zostanie zwolnione gdzieś indziej albo że nigdy nie zostało alokowane. Zapewnienie spójnej alokacji i zwalniania elementów wymaga zsynchronizowania działania funkcji new i tern i freea 1 1 . Trzeba zdecydować, czy wolimy gwa rancję, że pamięć zostanie zwolniona, czy że nie zostanie zwolnione nic, co nie powinno zostać zwolnione. Jeśli popełnimy tu błąd, będziemy mieli wiele problemów. W innych językach, takich jak Java, problem ten rozwiązuje za nas system usuwający nie użytki. Do tern.atu zarządzania zasobami wrócimy jeszcze w rozdziale 4. Usunięcie elementu z listy wymaga więcej pracy niż dodanie go: /* de/item: usuwa pie1wszą nazwę z listy listp */
Nameval *del i tem(Nameval *l i st p , char *name) { Nameval *p , *prev; prev = NULL; for (p = l i st p ; p ! = NULL; p = p->next) i f (strcmp (name , p->name) == O) { i f (prev == NULL) l i stp = p->next; el se prev->next = p->nex t ; free (p} ; return l i stp ; prev = p ; epri ntf ( " del i tem : % s n i e ma w l i §c i e " , name} ; return NULL; /* nie można się tu dostać */
Podobnie jak freea 1 1 funkcja del i tern nie zwalnia pola narne. Funkcja epri ntf wyświetla komunikat o błędzie i zamyka program, a więc nie działa zbyt zgrabnie. Eleganckie wychodzenie z sytuacji awaryjnych to trudna sztuka, której trzeba po święcić nieco więcej uwagi. Odkładamy to do rozdziału 4., w którym zobaczymy też implemen tację funkcji epri ntf. Te podstawowe struktury i operacje listowe wystarczą do większości zastosowań w typo wych programach. Nie oznacza to jednak, że poza nimi nie ma już nic więcej. W niektórych bibliotekach, takich jak biblioteka STL języka C + + , można znaleźć listy dwukierunkowe,
59
2.8. DRZEWA
a więc takie, w których każdy element ma dwa wskaźniki - n a element poprzedni i następny. Mimo iż listy takie zajmują trochę więcej pamięci, znaj dowanie w nich ostatniego elementu i usuwanie bieżącego to operacje o złożoności obliczeniowej rzędu 0(1). W niektórych przy padkach wskaźniki na elementy są alokowane osobno - nie z danymi, które łączą. Listy takie są nieco trudniejsze w użyciu, ale za to pozwalają na występowanie elementów w kilku listach jednocześnie. Oprócz wielkich zalet w sytuacjach, gdy trzeba dodawać i usuwać elementy w środku struk tury, listy doskonale nadają się do przechowywania elementów o zmiennym rozmiarze, zwłasz cza jeśli dostęp do nich uzyskuje się głównie według zasady LIFO (ang. last-in-first-out „ostatni przyszedł, pierwszy wyjdzie"), czyli tak jak w stosach. Jeżeli w programie użyto kilku stosów, które niezależnie od siebie są powiększane i zmniejszane, taki sposób wykorzystania pamięci jest bardziej efekcywny, niż gdyby do tego celu użyto tablic. Dodatkowym atutem tych list jest to, że doskonale sprawdzają się w przechowywaniu informacji jakoś wewnętrznie upo rządkowanych, ale o nieznanym z góry rozmiarze - takie cechy mają dane w postaci słów z dokumentów tekstowych. Jeśli jednak planujesz częste aktualizacje i potrzebujesz swobodnego dostępu do elementów, lepiej użyć nieliniowej struktury danych, np. drzewa lub tablicy mieszającej.
Ćwiczenie 2.7. Zaimplementuj kilka innych operacji listowych, np. kopiowanie, scalanie, dzielenie na dwie części oraz wstawianie elementu za i przed wybranym elementem. Czy obie operacje wstawiania są tak samo trudne w implementacji? W jakim stopniu możesz wykorzy stać napisane do tej pory procedury, a ile musisz napisać samodzielnie?
Ćwiczenie 2.8. Napisz funkcję reverse, która odwraca kolejność elementów listy, w wersji re kurencyjnej i iteracyjnej. Nie twórz nowych elementów listy, lecz wykorzystaj istniejące.
Ćwiczenie 2.9. Napisz ogólny typ Li st w języku C. Najprościej będzie to zrobić poprzez zapisanie w każdym elemencie listy wskaźnika typu voi d* wskazującego na dane. To samo zrób w języku C+ + przy użyciu szablonów i w Javie przez zdefiniowanie klasy przechowującej listy typu Obj ect. Jakie są słabe i mocne strony każdego z tych języków, jeśli chodzi o realizację tego zadania? Ćwiczenie 2.10. Zaprojektuj i zaimplementuj zestaw testów do weryfikacji swoich operacji listowych. Techniki testowania są opisane w rozdziale 6.
2.8. Drzewa Drzewo to hierarchiczna struktura danych, w której każdy element ma jakąś wartość, może wskazywać zero lub więcej innych elementów i jest wskazywany przez dokładnie jeden inny element. Wyjątkiem jest element zwany korzeniem drzewa, na który nie wskazuje żaden inny węzeł. Istnieje wiele rozmaitych rodzajów drzew odzwierciedlających nawet skomplikowane struktury, takie jak drzewa powstałe w wyniku analizy składni zdań albo programów czy też drzewa ro dzinne ukazujące relacje między ludźmi. Budowę drzew prześledzimy na przykładzie binarnego drzewa poszukiwań, czyli takiego, w którym każdy węzeł ma dwa połączenia z innymi węzła mi. Jest to najłatwiejszy typ drzew do implementacji, a przy okazji pozwalają zademonstrować podstawowe cechy tego rodzaju struktur danych. Każdy węzeł binarnego drzewa poszukiwań ma wartość i dwa wskaźniki l eft i ri ght na jego dzieci. Jeśli węzeł ma mniej niż dwoje dzieci, to wskaźnik brakującego potomka jest pusty. Cechą charakterystyczną binarnych drzew -
-
60
2. ALGORYTMY I STRUKTURY DANYCH
poszukiwań jest to, że potomkowie znajdujący się po lewej stronie każdego węzła mają od niego mniejsze wartości, a znajdujące się po prawej stronie - większe. Dzięki temu drzewa takie można szybko przeszukiwać w celu znalezienia określonego elementu albo stwierdzenia, że dany element nie istnieje. Struktura Nameva l w postaci drzewa jest bardzo prosta:
typedef struct Nameval Nameval ; struct Nameval { char *name ; i nt val ue; Nameva 1 *1 eft ; I* mniejszy *I Nameva 1 *ri ght ; /* większy *I }; Komentarze mni ej szy i wi ększy odnoszą się do właściwości łączy - dzieci po lewej stronie przechowują wartości mniejsze, a po prawej - większe od rodzica. Spójrzmy na konkretny przykład. Na poniższym rysunku widzimy fragment tablicy nazw znaków przedstawionej w postaci binarnego drzewa poszukiwań elementów Nameva l , posorto wanych według wartości przyporządkowanych im w standardzie ASCII: " smi l ey " Ox263A
/ °""-
/
�
" zeta "
"Aacut e "
Ox03b6
OxOOcl
/
/
"AEl i g " Ox00c6
°""-
�
" Ac i re " Ox00c2
Dzięki dodatkowym wskaźnikom na inne elementy w każdym węźle drzewa wiele operacji, które w listach i tablicach miały złożoność obliczeniową rzędu O(n), w drzewie ma złożoność tylko O(logn). Wskaźniki te pozwalają zmniejszyć złożoność czasową operacji poprzez zmniej szenie liczby węzłów, które trzeba sprawdzić, aby znaleźć szukany element. Binarne drzewo poszukiwań (które od tej pory będziemy dla uproszczenia nazywać po pro stu drzewem) tworzy się poprzez rekurencyjne dodawanie potomków węzłów, przechodząc od powiednio do lewej lub prawej gałęzi, aż do znalezienia odpowiedniego miejsca do zapisu ele mentu, który musi być poprawnie zainicjalizowanym obiektem typu Nameval - nazwa, wartość i dwa wskaźniki puste. Nowy węzeł jest dodawany jako liść, tzn. nie ma jeszcze żadnych potomków.
61
2.8. DRZEWA
/* insert: wstawia newp do treep i zwraca treep */
Nameval *i nsert (Nameval *treep , Nameval *newp) { i nt cmp ; i f (treep NULL) return newp ; cmp = strcmp ( newp->name, treep->name) ; i f (cmp O) wepri ntf ( " i nsert : el ement %s j uż występuj e newp->name) ; el se i f (cmp < O) t reep->l eft i nsert (treep->l eft , newp) ; el se treep->ri ght i nsert (treep->ri ght , newp) ; return t reep ; ==
==
został z i gnorowany " ,
=
=
Do tej pory nie wspomnieliśmy jeszcze o dublowaniu się elementów. Ta wersja funkcji i nsert nie pozwala na dodanie do drzewa dwóch takich samych elementów (cmp == O). Analogiczna procedura listowa nie zgłaszała w takim przypadku problemu, gdyż wymagałoby to przeszuka nia całej listy, co wydłuży czas wstawiania z 0(1) do O(n) . W drzewach takie sprawdzenie do stajemy za darmo w promocji, a poza tym nie ma jasności, jak miałaby wyglądać ta struktura, gdyby dopuścić w niej duplikaty. W niektórych przypadkach jednak dopuszczenie duplikatów może być konieczne lub najlepszym rozwiązaniem będzie ich ignorowanie. Procedura wepri ntf jest zmodyfikowaną wersją funkcji epri ntf. Drukuje komunikat o błędzie poprzedzony słowem warni ng, ale w przeciwieństwie do pierwowzoru nie zamyka programu. Drzewo, w którym długość drogi od korzenia do dowolnego liścia jest mniej więcej taka sama, nazywa się drzewem zrównoważonym (ang. balanced tree). Zaletą drzew zrównoważo nych jest to, że wyszukiwanie w nich dowolnego elementu ma złożoność obliczeniową rzędu O(logn), ponieważ w każdym kroku liczba możliwości zmniejsza się o połowę - tak jak w prze szukiwaniu binarnym. Jeśli elementy są dodawane do drzewa na bieżąco wprost z wejścia, może powstać drzewo niezrównoważone, a w najgorszych przypadkach może nawet przybrać wyjątkowo niefortunny kształt. Jeżeli np. dodawane elementy są już posortowane, program dla każdego kolejnego ele mentu będzie tworzył nową niższą gałąź. W ten sposób powstanie lista złożona po linii dowią zań prawostronnych, która będzie miała wszystkie wady zwykłej listy. Jeśli jednak elementy na wejściu będą się pojawiały w kolejności losowej, ryzyko wystąpienia takiej sytuacji jest bardzo mało prawdopodobne i najczęściej wówczas powstaje drzewo, lepiej lub gorzej zrównoważone. Implementacja drzewa gwarantującego zrównoważenie jest skomplikowana i właśnie z tego powodu istnieje tak dużo różnych rodzajów drzew. My w celach edukacyjnych pominiemy ten problem i przyjmiemy założenie, że dane są wystarczająco dobrze pomieszane, aby mogło po wstać drzewo zrównoważone. Kod funkcji l oo kup, przeszukującej drzewo, jest podobny do funkcji i n sert: I* lookup: szuka nazwy w drzewie treep */
Nameval *l ookup (Nameval *treep , char *name) { i nt cmp ; i f (treep == NULL) return NULL ; cmp = strcmp (name , treep->name) ; i f (cmp == O)
62
2. ALGORYTMY I STRUKTURY DANYCH
return treep ; el se i f (cmp < O) return l oo kup {treep->l eft , name) ; el se return l oo kup {treep->right, name) ;
W tym miejscu należy poczynić kilka uwag na temat funkcji l oo kup i i n sert. Po pierwsze wyglądają łudząco podobnie do prezentowanego już na początku rozdziału algorytmu przeszu kiwania binarnego. Nie jest to przypadkowe podobieństwo, gdyż algorytmy te działają według tej samej zasady „dziel i rządź", która ma logarytmiczną złożoność obliczeniową. Po drugie obie procedury są rekurencyjne. Gdybyśmy je napisali przy użyciu iteracji, były by jeszcze podobniejsze do przeszukiwania binarnego. W istocie iteracyjną wersję funkcji l o o kup można zrealizować, przerabiając jej wersję rekurencyjną. Jeśli nie znajdzie szukanego elementu, ostatnią czynnością funkcji l ookup jest zwrot wyniku swojego własnego wywołania - jest to tzw. rekurencja ogonowa (ang. taił recursion). Funkcję tę można łatwo przekształcić w iteracyjną, uzupełniając argumenty i wywołując ją ponownie. Najprościej byłoby użyć in strukcji goto, ale pętla wh i l e jest znacznie bardziej przejrzysta: I* nrlookup: nierekurencyjne wyszukiwanie nazwy name w drzewie treep *I
Nameval *nrl ookup {Nameval *treep , char *name) { i nt cmp ; whi l e (treep ! NULL) { cmp ; strcmp (name , treep->name) ; i f ( cmp O) return treep ; el se i f (cmp < O) treep treep->l eft ; el se treep treep->ri ght; ;
;;
return NULL;
Gdy już będziemy mogli poruszać się po drzewie, zaprogramowanie pozostałych operacji to już nic trudnego. Możemy wykorzystać niektóre techniki zastosowane w listach, np. napisanie ogólnej funkcji przeglądającej drzewo i wywołującej wybraną funkcję dla każdego węzła. Tym razem musimy jednak dokonać pewnych wyborów: kiedy będziemy wykonywać operacje na elementach i kiedy będziemy przetwarzać resztę drzewa? Odpowiedź na te dwa pytania zależy od tego, co reprezentuje drzewo. Jeśli służy do przechowywania danych w określonym porządku, jak binarne drzewo poszukiwań, najpierw przeglądamy lewą stronę, a potem prawą. Czasami jednak struktura drzewa odzwierciedla specyficzny porządek danych, tak jak w drzewie gene alogicznym. Wówczas kolejność odwiedzania liści zależy od rodzaju relacji reprezentowanych przez drzewo. Przy przeglądaniu poprzecznym drzewa (ang. in-order traversal) operacja jest wykonywana po przejrzeniu lewego poddrzewa, ale jeszcze przed przejrzeniem prawej części: I* applyinorder: wywołanie.fimkcjifi1 na węzłach drzewa treep przy zastosowaniu metodyprzeglądania poprzecznego *I
voi d app l yi norder (Nameval *treep , void {*fn ) (Nameval * , voi d*) , voi d *arg)
63
2.8. DRZEWA
i f (treep NULL) return ; appl yi norder(treep->l eft , fn , arg) ; {*fn) ( treep , arg ) ; appl yi norder(treep->ri ght , fn , arg) ; ==
T ę metodę przeglądania drzew stosuje się wówczas, gdy węzły trzeba odwiedzać w określo nym porządku, aby je np. po kolei wydrukować:
appl yi norder(treep, pri ntnv , "%s : %x\n " ) ; Na tej podstawie można też opracować dobrą technikę sortowania - wstaw elementy do drzewa, alokuj tablicę o odpowiednim rozmiarze, a następnie zastosuj technikę przeglądania poprzecznego w celu zapisania ich w tablicy w odpowiedniej kolejności.
Przeglądanie wsteczne (ang. post-order traversal) polega na wykonaniu działań na węźle dopie ro po odwiedzeniu jego obu potomków: /* applypostorder: wywołaniefankcjifi1 na węzłach drzewa treep przy zastosowaniu metody przeglądania wstecznego */
void appl ypostorder (Nameval *treep , voi d (*fn) (Nameval * , voi d*) , voi d *arg) i f (treep NULL) return ; appl ypostorder(treep->l eft , fn , arg) ; appl ypostorder (treep->ri ght , fn , arg) ; (*fn ) (treep , arg) ; ==
Przeglądanie wsteczne stosuje się wówczas, gdy operacja na węźle jest zależna od jego pod drzew. Przykładem jest tu np. obliczanie wysokości drzewa (weź większą z wysokości dwóch poddrzew i dodaj do niej jeden), rozmieszczanie drzewa w pakiecie graficznym (przydziel miej sce na stronie dla każdego poddrzewa, a następnie połącz je w celu wyznaczenia miejsca dla całego węzła) oraz obliczanie ogólnej ilości wymaganej pamięci. Trzecia możliwość to przeglądanie wzdłużne (ang. pre-order traversal), które rzadko się sto suje, a więc je pominiemy. Binarne drzewa poszukiwań są używane nieczęsto, ale tzw. B-drzewa, charakteryzujące się silnym rozgałęzieniem, są wykorzystywane do przechowywania informacji w pamięci drugiego stopnia. W codziennym programowaniu drzewa często wykorzystuje się do reprezentacji struk tury instrukcji i wyrażeń. Na przykład instrukcję
mi d
=
( l ow + hi gh) / 2 ;
można zaprezentować w postaci drzewa analizy składniowej widocznego na poniższym ry sunku. Aby obliczyć jej wartość, należy zastosować przeglądanie wsteczne z wykonaniem od powiedniej operacji w każdym węźle.
64
2. ALGORYTMY I STRUKTURY DANYCH
/ �
mi d
+
I
/ �
2
/ �
l ow
h i gh
Drzewom analizy składniowej dokładniej przyjrzymy się w rozdziale 9.
Ćwiczenie 2.11. Porównaj działanie funkcji 1 ookup i nrl ookup. Jak rekurencja wypada w po równaniu z iteracją pod względem wydajności? Ćwiczenie 2.12. Napisz procedurę sortującą przy użyciu przeglądania poprzecznego. Jaką zło żoność czasową ma ten algorytm? W jakich warunkach działałby najgorzej? Jak wypada pod względem wydajności w porównaniu z naszym algorytmem szybkiego sortowania i wersją bi blioteczną? Ćwiczenie 2.13. Zaprojektuj i zaimplementuj zestaw testów do sprawdzenia poprawności swoich trzech procedur.
2.9. Tablice mieszania Tablice mieszania to jedno z najwspanialszych osiągnięć informatyki. Łączą w sobie zalety zwykłych tablic i list, do których zastosowano pewne koncepcje matematyczne. To wszystko sprawia, że są doskonałym narzędziem do przechowywania i wyszukiwania informacji, które mogą się zmieniać. Typową realizacją tablicy mieszania jest tablica symboli, która umożliwia powiązanie wartości (dane) z dowolnym elementem zmieniającego się zbioru łańcuchów (klu czy). Twój ulubiony kompilator prawie na pewno używa takiej tablicy do przechowywania in formacji o wszystkich zmiennych w Twoich programach. Przeglądarki internetowe w tablicach symboli mogą zapisywać informacje o niedawno odwiedzanych stronach, a gdy łączysz się z Internetem, to zapewne w takiej tablicy zapisujesz nazwy ostatnio odwiedzanych domen i ich adresy IP. Idea jest taka, aby przepuścić klucze przez specjalną funkcję mieszającą w celu wygenerowania z nich wartości funkcji mieszania równomiernie rozprowadzonych w niewielkim zbiorze liczb całkowitych. Wartości funkcji mieszania są używane jako indeksy podczas zapisywania danych w tablicy. W języku Java dostępny jest standardowy interfejs do tablic rozproszonych, natomiast w C i C+ + najczęściej z każdą wartością funkcji mieszania (tzw. kubełkiem - ang. bucket) wiąże się listę elementów, dla których wartość ta jest wspólna, jak widać na rysunku przedstawionym na następnej stronie. W praktyce często funkcja mieszania jest od początku dostępna, a tablica odpowiedniego rozmiaru jest alokowana na etapie kompilacji. Każdy element tej tablicy tworzy listę powiąza nych elementów, które mają tę samą wartość funkcji mieszającej. Innymi słowy, tablica mie szania n elementów to tablica list, których średnia długość wynosi n/(rozmiar tablicy). Pobiera nie elementów to operacja o złożoności rzędu 0(1), ale pod warunkiem, że zostanie użyta dobra funkcja mieszająca i listy nie będą zbyt długie.
65
2.9. TABLICE MIESZANIA
symtab[NHASH] :
Łańcuchy elementów w kubełkach: -
NULL
NULL
name l
name 2
NULL
va l u e 1
value 2
NU LL -
NULL
NULL
name 3
NULL
value 3
Dzięki temu, że tablica mieszania to w istocie tablica list, jej elementy mają taki sam typ, jak elementy listy:
typedef struct Nameval Nameval ; struct Nameval { char *name ; i nt val u e ; I* Następny w lmicuchu */ Nameval *nex t ; }; Nameva1 *symtab [NHASH] ; /* Tablica symboli */ Do zarządzania poszczególnymi łańcuchami elementów można wykorzystać techniki listowe opisane w części 2.7. Jeśli mamy dobrą funkcję mieszającą, to praca pójdzie nam jak po maśle - wybieramy tylko kubełek i przechodzimy wzdłuż listy, aby znaleźć idealne dopasowanie. Poniżej prezentujemy kod procedury przeszukującej tablicę mieszania i wstawiającej do niej wartości. Jeśli określony element zostanie znaleziony, funkcja go zwróci. Jeżeli element nie zo stanie znaleziony, a sygnalizator create będzie włączony, funkcja doda go do tablicy. Ta funk cja również nie tworzy kopii nazwy, gdyż przyjęte zostało, że wywołujący sam wykonał jej ko pię zapasową. /* loo/...--i1p: znajduje nazwę w symtab lub opcjonalnie ją tworzy */
Nameval * l ookup {char *name , i nt create , i nt val ue) { i nt h ; Nameval *sym; h hash (name) ; for (sym symtab [h] ; sym ! = NULL; sym = sym->next) i f (strcmp (name , sym->name) O) return sym; i f ( create) { sym = (Nameval *) emal l oc {s i zeof (Nameval } ) ; sym->name = name; /* Zakładamy, że ma przydzieloną pamięć gdzieś indziej */ sym->val ue val ue; sym->next symtab [h] ; symtab [h] sym; =
=
==
=
=
return sym;
66
2. ALGORYTMY I STRUKTURY DANYCH
Takie połączenia procedur przeszukiwania i wstawiania elementów są spotykane bardzo często. Bez tego konieczne byłoby wykonywanie tej samej pracy dwa razy, ponieważ musieliby śmy pisać instrukcje tego rodzaju:
i f ( l ookup ( " name " ) == NULL) addi tem(newi tem ( "name" , val ue) ) ; przez co wartość funkcji mieszającej byłaby obliczana dwukrotnie. Jaki rozmiar powinna mieć tablica? Ogólnie przyjmuje się, że powinna mieć rozmiar wy starczający do tego, aby w każdym łańcuchu znajdowało się przynajmniej kilka elementów, dzięki czemu złożoność obliczeniowa operacji znajdowania elementów wyniesie 0(1) . Na przy kład kompilator może mieć rozmiar tablicy ustawiony na kilka tysięcy, ponieważ duży plik źró dłowy może się składać z kilku tysięcy wierszy kodu, przy czym nie przewiduje się, aby iden tyfikatorów było więcej niż wierszy kodu. Teraz trzeba zdecydować, co nasza funkcja mieszająca h a s h będzie obliczać. Jej wynik musi być określony, powinna być szybka i równomiernie rozmieszczać dane w tablicy. Jeden z naj częściej stosowanych algorytmów obliczania wartości mieszania dla łańcuchów dodaje każdy bajt łańcucha do wielokrotności dotychczas uzyskanej wartości mieszania. Mnożenie rozpro wadza bity nowego bajta w już obliczonej wartości. Gdy pętla zakończy działanie, powinniśmy otrzymać wartość powstałą z wymieszania bajtów wprowadzonych na wejściu. W wyniku doświad czeń stwierdzono, że dla łańcuchów znaków ASCII najlepszymi mnożnikami są liczby 3 1 i 37.
enum { MULT I PLIER
=
31} ;
/* hash: oblicza wartościfunkcji mieszającej dla łmicuchów */
unsi gned i nt hash (char *str) { unsi gned i nt h ; unsi gned char * p ; h = O; for ( p (unsi gned char *} str; * p ! = ' \O ' ; p++) h MULT I PL I ER * h + *p ; return h % NHASH ; =
=
W obliczeniach wykorzystywane są zmienne typu unsi gned char, ponieważ w językach C i C + + nie jest określone, czy zmienna typu char ma mieć znak, czy nie, a my chcemy, aby wartość funkcji mieszającej była dodatnia. Wynikiem funkcji mieszającej jest obliczona wartość podzielona modulo przez rozmiar ta blicy. Jeśli funkcja mieszająca rozprowadza wartości kluczy równomiernie, to dokładny roz miar tablicy nie ma znaczenia. Nigdy jednak nie można mieć pewności, że funkcja mieszająca będzie działać idealnie. Istnieją takie zestawy danych wejściowych, przy których może zawieść nawet najlepsza funkcja. Dlatego warto rozmiar tablicy określić liczbą pierwszą, co da nam gwarancję, że rozmiar tablicy, mnożnik i spodziewane wartości nie będą przynajmniej miały wspólnego dzielnika. Doświadczenie pokazuje, że dla zróżnicowanych zbiorów łańcuchów trudno jest napisać lep szą funkcję mieszającą od powyższej, ale za to z łatwością można stworzyć taką działającą od niej gorzej. We wczesnych wersjach Javy była dostępna funkcja mieszająca, która lepiej się sprawdzała, gdy używano jej do długich łańcuchów znaków. Jeśli łańcuch składał się z więcej niż 16 znaków, oszczędzano czas, sprawdzając w regularnych odstępach czasu tylko jego 8 lub 9 pierwszych znaków. Niestety, słabe właściwości statystyczne funkcji wszystkie te oszczędności obracały wniwecz. Pomijanie części znaków powodowało, że niektórych łańcuchów nie dało się
2.9. TABLICE MIESZANIA
67
rozróżnić. Tak było np. z nazwami plików, które często na początku mają identyczny długi ciąg znaków określający katalog, a różnią się tylko kilkoma znakami na końcu, np . .java i .class. Większość adresów URL zaczyna się od ciągu http://, a kończy ciągiem .html, a więc różnice występują głównie w ich części środkowej. Opisywana funkcja mieszająca często sprawdzała tylko tę wspólną część nazwy, co powodowało powstawanie bardzo długich łańcuchów elemen tów, które utrudniały przeszukiwanie tablicy. Aby rozwiązać problem, pierwotną funkcję mie szającą zastąpiono inną, podobną do tej, którą przedstawiliśmy powyżej (z mnożnikiem 37), sprawdzającą każdy znak w łańcuchu. Funkcja mieszająca, która dobrze działa na danych jednego rodzaju (np. krótkich nazwach zmiennych), nie musi wcale dobrze się sprawdzać, gdy poda się jej dane innego typu, takie jak np. adresy URL. Dlatego przed użyciem funkcję taką należy zawsze dokładnie przetestować. Czy dobrze miesza krótkie łańcuchy? Jak radzi sobie z długimi? A jak się sprawdza w przypad ku łańcuchów o tej samej długości, które tylko nieznacznie się różnią? Działaniu funkcji mieszających można poddawać nie tylko łańcuchy. Jeśli np. przeprowa dzamy symulacje fizyczne, w tablicach mieszania możemy przechowywać trójwymiarowe współrzędne cząsteczek. To pozwala zaoszczędzić pamięć, gdyż zamiast tablicy trójwymiarowej o złożoności pamięciowej O(wartośćx x wartośćy x wartośćz) użylibyśmy tablicy jednowymiaro wej o złożoności O(liczba cząsteczek). Doskonały przykład wykorzystania tablic mieszania można znaleźć w programie Supertra ce Gerarda Holzmanna służącym do analizy protokołów i systemów współbieżnych. Program ten zbiera informacje o wszystkich możliwych stanach badanego systemu, następnie przepusz cza je przez funkcję mieszającą, aby wygenerować adres pojedynczego bitu w pamięci. Jeśli bit ma wartość 1, oznacza to, że był już wcześniej widziany. W przeciwnym razie stan ten jeszcze nie występował. Mimo iż program Supertrace korzysta z tablicy o rozmiarze wielu bajtów, to w każdym kubełku przechowuje tylko po jednym bicie. W związku z tym w strukturze tej nie ma łańcuchów elementów. Jeżeli wystąpi kolizja spowodowana tym, że dla dwóch stanów funkcja mieszająca zwróci tę samą wartość, program tego nie spostrzeże. Autorzy liczą na to, że prawdopodobieństwo wystąpienia kolizji jest bardzo niskie (nie musi być zerowe, gdyż pro gram Supertrace i tak daje tylko przybliżone wyniki). Dlatego funkcję mieszającą zaprojekto wano niezwykle skrupulatnie. Zastosowano w niej cykliczną kontrolę nadmiarową (ang. cyclic redundancy check), czyli funkcję, która dokładnie miesza dane. Tablice mieszania doskonale sprawdzają się jako tablice symboli, gdyż oczekiwany czas do stępu do któregokolwiek elementu wynosi w nich 0(1). Nie oznacza to jednak, że są idealne. Jeśli zastosuje się niskiej jakości funkcję mieszającą albo ustawi zbyt mały rozmiar tablicy, to mogą powstać za długie listy. Ponieważ listy te są nieposortowane, czas dostępu do elementów może wynosić O(n). Do elementów listy nieposortowanej nie można uzyskać bezpośredniego dostępu. Można jednak łatwo je policzyć, alokować tablicę, wypełnić ją wskaźnikami na te elementy i posortować. Jeżeli z tablicy mieszania korzysta się we właściwy sposób, oferowany przez nią stały czas dostępu do elementów, a także czas ich usuwania i wstawiania są nieosią galne dla innych struktur danych.
Ćwiczenie 2.14. Nasza funkcja mieszająca doskonale działa na łańcuchach znaków. Niemniej jednak można zmusić ją do gorszego działania, podając jej pewne specyficzne dane. Opracuj taki zestaw danych, który sprawi, że funkcja ta będzie działać źle. Czy łatwiej jest znaleźć złe dane dla różnych wartości stałej NHASH?
Ćwiczenie 2.15. Napisz funkcję umożliwiającą dostęp do kolejnych elementów nieposortowa nej tablicy mieszania. Ćwiczenie 2.16. Zmodyfikuj funkcję l oo kup w taki sposób, aby gdy średnia długość listy prze kroczy wartość x, tablica była automatycznie powiększanay razy i tworzona od nowa.
68
2. ALGORYTMY I STRUKTURY DANYCH
Ćwiczenie 2.17. Zaprojektuj funkcję mieszającą do przechowywania dwuwymiarowych współ rzędnych punktów. Czy łatwo jest ją dostosować do użytku ze współrzędnymi innego typu, np. zastąpić wartości całkowite zmiennoprzecinkowymi albo zamiast współrzędnych kartezjań skich użyć współrzędnych biegunowych czy też zwiększyć liczbę wymiarów?
2. 1 O. Podsumowanie Wyboru algorytmu należy dokonywać w kilku krokach. Po pierwsze należy sprawdzić poten cjalnie dostępne algorytmy i struktury danych. Następnie trzeba oszacować, jaką ilość danych program będzie prawdopodobnie przetwarzał. Jeśli nie jest ich dużo, najlepiej wybrać rozwią zanie, które nie będzie zbyt skomplikowane. Jeżeli zbiór danych może się powiększać, to już na wstępie odrzuć struktury o stałym rozmiarze. Później skorzystaj, jeśli to możliwe, z biblioteki używanego języka programowania. W przeciwnym wypadku napisz prostą i łatwą do zrozu mienia własną implementację albo pożycz ją od kogoś innego. W następnej kolejności przete stuj swój program. Dopiero gdy okaże się, że działa on za wolno, należy poszukać bardziej za awansowanych rozwiązań. Mimo dostępności wielu struktur danych, z których część jest zoptymalizowana do pew nych specyficznych użyć, w większości programów zastosowanie znajdują przede wszystkim tablice, listy, drzewa i tablice mieszania. Wszystkie one oferują podstawowy zestaw operacji, tzn. dodawanie, wyszukiwanie i usuwanie elementów. Każda operacja ma oszacowaną spodziewaną złożoność obliczeniową, dzięki czemu można stwierdzić, czy dana struktura (lub jej wersja implementacyjna) nadaje się do użycia w kon kretnym przypadku. Zaletą tablic jest stały czas dostępu do elementów, ale problematyczne jest ich powiększanie i zmniejszanie. Listy dobrze poddają się operacjom zwiększania i zmniejsza nia, ale za to czas dostępu do losowo wybranych elementów jest w nich rzędu O(n) . Drzewa i tablice mieszania łączą w sobie zalety obu poprzednich struktur, ale tylko wówczas, jeśli będzie przestrzegany warunek zrównoważenia. Istnieją jeszcze inne struktury danych, zoptymalizowane pod kątem rozwiązywania specy ficznych problemów, ale większość oprogramowania powstaje przy użyciu tych kilku struktur opisanych w niniejszym rozdziale.
Lektura uzupełniająca Przystępny opis wielu przydatnych algorytmów można znaleźć w cyklu książek Boba Sedge wicka pt. Algorithms (Addison-Wesley). Obszerne omówienie funkcji mieszających i algoryt mów zmiany rozmiaru tablic można znaleźć w wydanej w 1998 roku książce Algorithms in C + + tego samego autora. Wyczerpujące i rygorystyczne analizy wielu algorytmów zamieścił w swojej książce Sztuka programowania (WNT, 2002) Donald Knuth. W tomie trzecim zostały omówione algorytmy sortowania. Opis programu Supertrace znajduje się w książce Design and Validation of Computer Proto cols Gerarda Holzmanna wydanej przez wydawnictwo Prentice Hall w 1991 roku. Jon Bentley i Doug Mcllroy w artykule Engineering a Sort Function, opublikowanym w cza sopiśmie „Software - Practice and Experience" 1993, R. 23, nr 1 1, na s. 1249 - 1 265 opisali technikę tworzenia szybkiej i niezawodnej implementacji algorytmu szybkiego sortowania.
3
Projektowanie i implementacja
Jeśli pokażesz mi swoje schematy blokowe, a ukryjesz tablice, to i tak wszystko będzie dla mnie tajemnicą; jeśli natomiast pokażesz mi swoje tablice, to nie będę już potrzebował schematów blokowych. Wszystko stanie się jasne.
Frederick P. Brooks jr, Mityczny osobomiesiąc (przeł. A. Ehrlich)
Powyższy cytat z klasycznej książki Brooksa sugeruje, że przy tworzeniu nowego programu najważniejszy jest wybór odpowiednich struktur danych. Dzięki wyborowi właściwych struktur danych ułatwione jest pisanie algorytmów, a wtedy praca nad całym programem staje się rów nież prostsza. Przedstawiony pogląd jest znacznie uproszczony, ale zgodny z rzeczywistością. W poprzed nim rozdziale zrobiliśmy przegląd podstawowych struktur danych, które wchodzą w skład prawie każdego programu. W tym rozdziale wykorzystamy je do zaprojektowania średniej wielkości programu. Pokażemy, jak wybór struktur danych jest zależny od rodzaju problemu do rozwią zania, oraz wykażemy, że kod źródłowy każdego programu o wiele łatwiej zrozumieć, gdy zna się budowę użytych w nim struktur danych. Co ważne, przy takim podejściu do sprawy na ostateczny kształt projektu niewielki wpływ ma wybór języka programowania. Najpierw opracujemy abstrakcyjny model aplikacji, a na stępnie zaimplementujemy go w językach C, Java, C + + , Awk i Perl. Porównując te różne im plementacje, dowiemy się, jak specyficzne właściwości języków programowania mogą pomagać programiście lub utrudniać mu pracę oraz kiedy język nie ma żadnego znaczenia. Projekt programu może w niewielkim stopniu odzwierciedlać struktury specyficzne dla języka programowania, który zostanie użyty do jego implementacji, ale nie powinien jednak być przez niego zdomi nowany. Problem, którego rozwiązanie przedstawimy, jest nietypowy, ale ma pewne cechy właściwe wszystkim programom - jakieś dane przyjmuje, coś zwraca, a napisanie algorytmów przetwa rzających te dane wymaga nieco pomysłowości. Napiszemy program, który generuje dający się czytać tekst w języku angielskim. Gdybyśmy postawili na generowanie losowych liter lub wyrazów, to otrzymany wynik byłby bezsensowny. Na przykład program wybierający losowe litery i spacje mógłby zwrócić następujący wynik:
xptmxgn xusaj a afqnzgxl lhi d l wcd rj dj uvpydrl wnjy
70
3. PROJEKTOWANIE I IMPLEMENTACJA
Nie ma czym się chwalić. Gdybyśmy wybierali litery według częstości ich występowania w angielskich tekstach, otrzymalibyśmy coś w tym rodzaju:
i dtefoae tcs trder j c i i ofdsl nqetacp t ol a Też niewiele lepiej. Dobór losowych słów ze słownika również nie da zadowalającego efektu:
pol ydactyl equatorial spl ashi l y j owl verandah ci rcumscri be Aby uzyskać lepsze wyniki, jest nam potrzebny dobry model statystyczny o określonej struk turze, np. informacje o częstości występowania w języku całych wyrażeń. Gdzie można coś takiego znaleźć? Moglibyśmy np. sporządzić duży korpus tekstów w języku angielskim i szczegółowo go przeanalizować, ale znamy lepszy i przyjemniejszy sposób. Warto sobie uświadomić, że na pod stawie dowolnego tekstu można zbudować model statystyczny, który będzie pokazywał sposób użycia języka w tym tekście. Następnie na podstawie tego modelu można generować losowe teksty o charakterystyce podobnej do oryginału.
3. 1 . Algorytm łańcucha Markowa Eleganckim sposobem realizacji tego zadania jest technika nazywana algorytmem łańcucha Markowa (ang. Markov chain algorithm). Jeśli przyjmiemy założenie, że na wejściu będą poja wiać się ciągi nachodzących na siebie wyrażeń, to algoryun każde z nich podzieli na dwie części:
przedrostek złożony z kilku słów i przyrostek złożony z jednego słowa. Algorytm łańcucha Markowa tworzy wyrażenia poprzez losowy dobór przyrostków, które dołącza do przedrostków, zgodnie ze strukturą statystyczną tekstu, w naszym przypadku oryginalnego. Metoda dobrze sprawdza się dla wyrażeń trójwyrazowych, a więc złożonych przy użyciu przedrostków skła dających się z dwóch wyrazów:
w zmi ennych � i � zap i s z dwa pi erwsze s łowa z tekstu wydrukuj w, i w, pęt l a : wybi erz l os owo z tekstu następni k w, przedrostka w, w, wydrukuj w, w, i w, zami eń na w, i w, powtórz pętl ę W ramach przykładu załóżmy, że chcemy wygenerować tekst na podstawie dwóch zdań z oryginalnej wersji motta tego rozdziału:
Show your fl owcharts and conceal your tab l es and I wi l l be mys t i fi ed . Show your tab l es and your fl owcharts wi l l be obvi ous . (kon i ec) Oto kilka przykładowych par wyrazów wejściowych i wyrazów, które występują po nich:
3.1.
71
ALGORYTM ŁAŃCUCHA MARKOWA
Przedrostki
Przyrostki
Show your
fl owcharts tabl es
your f1 owcharts
and wi 1 1
f1 owcharts and
concea l
f1 owcharts wi 1 1
be
your tabl es
and and
wi 1 1 be
mysti fi ed . obv i ous .
be mysti fi ed .
show
be obvi ous
( koni ec)
Algorytm Markowa najpierw wydrukuje przedrostek S how your, a następnie losowo wybie rze wyraz fl owcharts lub tabl es. W pierwszym przypadku przedrostkiem stanie się wyrażenie your fl owc harts, a kolejnym wybranym słowem będzie and lub wi 1 1 . Gdyby jednak na po czątku wybrał wyraz tabl es, następnym byłoby słowo and. Działania te będą kontynuowane, aż na wyjściu zostanie wygenerowana odpowiednia ilość danych lub algorytm napotka znacz nik końca. Nasz program będzie wczytywał fragment tekstu po angielsku, a następnie przy użyciu al gorytmu Markowa będzie generował nowy tekst, biorąc pod uwagę częstość występowania wy rażeń o stałej długości. Liczba wyrazów, z których będzie się składał przedrostek, to parametr (w naszym przypadku dwa). Gdybyśmy skrócili przedrostek, otrzymalibyśmy mało zrozumiały tekst, a gdybyśmy go przedłużyli, program powielałby bez zmian duże części oryginalnego tek stu. W przypadku języka angielskiego wybór dwóch słów i uzupełnienie ich trzecim to bardzo dobra decyzja. W ten sposób powstanie coś w rodzaju udziwnionej wersji pierwotnego tekstu. Czym jest słowo? Odpowiedź zdaje się oczywista: słowo to ciąg znaków alfabetu. Wydaje się też, że warto pozostawić znaki przestankowe, które powodują, że words i words . to dwa różne słowa. Jeśli dobór słów uzależnimy częściowo od występowania tych znaków, a więc po średnio od pewnych zasad gramatycznych, to wygenerujemy tekst o większych walorach este tycznych. Wówczas musimy jednak liczyć się z tym, że w tekście mogą wystąpić nawiasy i cu dzysłowy nie do pary. Zatem podsumowując te rozważania - słowo zdefiniujemy jako ciąg znaków umieszczony między dwiema spacjami. Ta decyzja pozwala na użycie tekstu wejścio wego w dowolnym języku oraz pozostawienie znaków przestankowych dołączonych do słów. Ponadto będziemy mieli ułatwioną pracę nad programem, ponieważ w większości języków programowania dostępne są narzędzia do dzielenia tekstu na słowa według spacji. Przy tej technice każde słowo i każde wyrażenie dwu- i trzywyrazowe zwracane na wyjściu musi znajdować się też w tekście wejściowym. Ale powinny się też pojawiać dłuższe wyrażenia, powstałe z tych krótszych składników. Oto kilka zdań wygenerowanych przez program, nad którym będziemy pracować w tym rozdziale. Powstały one na podstawie fragmentu oryginalnej wersji rozdziału 7. powieści pt. Słońce też wschodzi Ernesta Hemingwaya: As I started up the undershirt anto his chest black, and big stornach muscles bulging under the light. „You see them?" Below the line where his ribs stopped were two raised white welts. „See on the forehead." „Oh, Bren, I love you." „Let's not talk. Talking's all bilge. I'm going away tomorrow." „Tomorrow?" „Yes. Didn't I say so? I am." „Let's have a drink, then." Mieliśmy sporo szczęścia, że znaki przestankowe się nie pomieszały. Nie zawsze jest tak dobrze.
72
3. PROJEKTOWANIE I IMPLEMENTACJA
3.2. Wybór struktury danych Jaką ilość danych będzie przetwarzać nasz program? Jak szybki musi on być? Wydaje się, że program powinien być w stanie wczytywać nawet całe książki, a więc powinien być przygoto wany na zbiory danych wejściowych o rozmiarze n = 100 tysięcy słów i więcej. Na wyjściu bę dzie zwracał setki, a nawet tysiące słów i czas na wykonanie zadania powinien być liczony ra czej w sekundach niż w minutach. Przy 1 00 tysiącach słów n ma bardzo dużą wartość, więc jeśli zależy nam na wydajności, nie możemy zastosować uproszczonych algorytmów, lecz musimy poszukać bardziej zaawansowanych rozwiązań. Aby algorytm Markowa rozpoczął generowanie tekstu, musi wpierw otrzymać do dyspozycji cały tekst wejściowy, który musimy w jakiś sposób dla niego zapisać. Jednym z możliwych rozwiązań jest wczytanie i zapisanie całego tekstu w postaci długiego łańcucha znaków, ale przecież zależy nam na tym, aby podzielić go na słowa oddzielane spacjami. Jeśli użyjemy ta blicy wskaźników na poszczególne słowa, to generowanie wyniku będzie ułatwione: przed wy drukowaniem każdego kolejnego słowa przeglądamy tekst wejściowy, aby sprawdzić, jakie słowa mogą wystąpić za ostatnio wygenerowanym przedrostkiem i losowo wybieramy jedno z nich. To jednak oznacza, że za każdym razem musimy przejrzeć wszystkie 1 00 tysięcy słów. Zatem wygenerowanie tysiąca słów oznaczałoby konieczność wykonania setek milionów porównań łańcuchów, co nie byłoby szybkie. Inną możliwością jest zapisanie tylko po jednym egzemplarzu każdego wyrazu i sporządze nie listy określającej miejsce występowania każdego z nich. To przyspieszyłoby proces wyszuki wania kolejnych słów. Moglibyśmy również użyć tablicy mieszania, jak opisana w rozdziale 2., ale ta konkretna implementacja nie najlepiej spełnia wymagania algorytmu Markowa, który musi szybko znajdować wszystkie przyrostki danego przedrostka. Potrzebna jest nam struktura danych, która będzie lepiej reprezentować przedrostki i zwią zane z nimi przyrostki. Program będzie działał dwuprzebiegowo. W pierwszym przebiegu bę dzie pobierał dane wejściowe i budował strukturę danych reprezentującą wyrażenia, a w drugim - wykorzystywał tę strukturę do generowania losowego tekstu. W obu przypadkach musimy szybko znajdować przedrostki. W pierwszym przebiegu w celu zaktualizowania listy możli wych przyrostków, a w drugim w celu wybrania losowego przyrostka spośród dostępnych. Do realizacji tego planu nadaje się tablica mieszania, której klucze będą reprezentować przedrostki, a wartości będą zbiorami odpowiadających im przyrostków. Na potrzeby przykładu przyjmiemy założenie, że przedrostki składają się z dwóch wyra zów, a więc każde słowo na wyjściu będzie zależało od pary poprzedzających je słów. Liczba słów w przedrostku nie ma wpływu na strukturę programu i powinno być możliwe używanie przedrostków dowolnej długości, ale objaśnienia będą łatwiejsze do zrozumienia, jeśli posłu żymy się konkretnymi liczbami. Zgodnie ze standardową terminologią przyjętą w odniesieniu do algorytmów Markowa przedrostek wraz ze zbiorem wszystkich jego przyrostków będziemy nazywać stanem. Dla każdego przedrostka musimy zapisać zbiór wszystkich jego przyrostków, aby móc ich później użyć. Przyrostki są dodawane pojedynczo w sposób nieuporządkowany. Ponieważ nie wiadomo, ile ich będzie, musimy użyć struktury danych, której rozmiar można łatwo i szybko zwiększać, takiej jak np. lista lub tablica dynamiczna. Przy generowaniu tekstu wyjściowego dla każdego przedrostka musimy losowo wybrać jeden przyrostek ze zbioru. Niczego nie bę dziemy usuwać. Co zrobimy, gdy jakieś wyrażenie będzie występować częściej niż raz? Przykładowo wyra żenie „might appear twice" może wystąpić dwa razy, a wyrażenie „might appear once" - tylko raz. Taką sytuację możemy zaprezentować poprzez umieszczenie wyrazu „twice" na liście przy rostków przedrostka „might appear" dwa razy albo tylko jeden raz i dołączyć do niego licznik z wartością 2. Wypróbowaliśmy obie możliwości. Wersja bez licznika jest łatwiejsza, gdyż przy
3.3. BUDOWA STRUKTURY DANYCH W JĘZYKU C
73
dodawaniu przyrostka nie trzeba sprawdzać, czy już znajduje się na liście. Poza tym doświad czenia wykazały, że różnica w wydajności tych dwóch podejść jest zaniedbywalnie mała. Podsumujmy. Każdy stan składa się z przedrostka i listy przyrostków. Wszystkie informa cje są przechowywane w tablicy mieszania, w której kluczami są przedrostki. Wszystkie przed rostki są złożone z takiej samej stałej liczby słów. Jeśli określony dla danego przedrostka przy rostek występuje częściej niż raz, każde jego wystąpienie zostanie zapisane w liście z osobna. Kolejna decyzja, którą musimy podjąć, dotyczy sposobu reprezentacji samych słów. Najła twiej byłoby przechowywać je w postaci pojedynczych łańcuchów. Ze względu na to, że więk szość tekstów składa się z pewnego zbioru wielokrotnie powtarzających się słów, moglibyśmy zaoszczędzić pamięć, tworząc drugą tablicę mieszania, w której zapisalibyśmy po jednym eg zemplarzu każdego słowa. Wpłynęłoby to też korzystnie na szybkość pracy z tablicą mieszania, ponieważ zamiast poszczególnych znaków moglibyśmy porównywać tylko wskaźniki na nie każde słowo miałoby niepowtarzalny adres. Implementację tej techniki pozostawiamy Czytelni kowi jako ćwiczenie, a w naszym programie każdy łańcuch będziemy przechowywać osobno.
3.3. Budowa struktury danych w języku C Zaczniemy od implementacji w języku C, a mówiąc konkretnie, od zdefiniowania kilku stałych.
enum { NPREF = 2 , /* Liczba słów w przedrostku */ NHASH 409 3 , /* Rozmiar tablicy mieszania przechowującej stany */ MAXGEN = 10000 /* Maksymalna liczba słów, jaka może zostać wygenerowana *I }; =
W powyższej deklaracji określiliśmy liczbę składników przedrostka (NPREF), rozmiar tabli cy mieszania (NHASH) oraz limit liczby słów, jaka może zostać wygenerowana (MAXGEN). Dzięki temu, że NPREF jest stałą o wartości znanej już na etapie kompilacji, łatwiej będzie nam zarzą dzać pamięcią. Domyślny rozmiar tablicy został ustawiony na dość dużą wartość, ponieważ spodziewamy się na wejściu dużych zbiorów danych, nawet całych książek. Wybraliśmy war tość 4 093 z tego względu, że nawet jeśli dane wejściowe będą składać się z 10 tysięcy różnych przedrostków (par słów), to na jeden łańcuch średnio przypadną jedynie dwa lub trzy przed rostki. Im większy początkowy rozmiar tablicy, tym krótsze łańcuchy i szybsze przeszukiwanie struktury. Ten program piszemy tylko dla zabawy, a więc wydajność nie jest dla nas kluczowa. Jeśli jednak ustawimy zbyt mały rozmiar tablicy, przetwarzanie spodziewanego zbioru danych może trwać bardzo długo. Natomiast z drugiej strony, jeśli przesadzimy z tym rozmiarem, struktura może nam się nie zmieścić w dostępnej pamięci. Przedrostki można przechowywać w tablicy słów. Elementy tablicy mieszania reprezento wane jako typ danych o nazwie State będą łączyć listy przyrostków typu Suffi x z odpowied nimi przedrostkami:
typedef struct State State; typedef struct Suffi x Suffi x ; struct State { /*przedrostek + lista przyrostków *I char *pref [NPREF] ; /* Słowa przedrostka */ /* Lista przyrostków */ Suffi x *suf; State *nex t ; /* Następny w tablicy *I }; struct Suffi x { /* lista przyrostków */
74
};
3. PROJEKTOWANIE I IMPLEMENTACJA char *word ; Suffi x *next ;
/* Przyrostek */ /* Następny na liście przyrostków */
State *statetab [NHASH] ; /* Tablica mieszania do przechowywania stanów */ Na poniższym rysunku widać graficzną reprezentację przedstawionych struktur. Ta b l ica
statetab :
Egze mp larz struktury
State :
" Show"
pref [ O J
"you r "
pref[ l ] suf n ext
Egze m p l a rz struktu ry
Suffi x : word
I n ny egzemplarz struktury
" fl oweha rts "
n ext
State : ���� pre f [ O J pre f [ l ] suf next
I n ny egze m p l a rz struktury
Suffi x : word
" tabl e s "
next
Dla naszych przedrostków przechowywanych w postaci tablicy słów potrzebujemy funkcji mieszającej. Bez trudu możemy zmodyfikować funkcję mieszającą dla łańcuchów z rozdziału 2., tak aby przemierzała za pomocą pętli łańcuchy w tablicy i zwracała wartości powstałe ze zmieszania połączeń łańcuchów. /* hash: oblicza wartość mieszania dla tablicy NPREF łmicuchów */
unsi gned i nt hash (char *s [NPREF] ) { unsi gned i nt h ; unsi gned char *p; i nt i ; h ; O; for ( i ; O ; i < NPREF; i ++) for (p ; (unsi gned char *) s [i ] ; *p ! ; ' \O ' ; p++) h ; MULT I PL I ER * h + *p ; return h % NHASH ;
Podobna modyfikacja procedury przeszukującej kończy implementację naszej tablicy mie szania:
3.3. BUDOWA STRUKTURY DANYCH W JĘZYKU C
75
/* lookup: szuka przedrostka i w razie potrzeby go tworzy */ /* Zwraca wskaźnik na znaleziony lub utworzony przedrostek albo NULL w pozosta61ch przypadkach. *I /* Przy tworzeniu przedrostków nie jest 1'\l)'WOQJWana funkcja strdup, co oznacza, że łańcuchów nie wolno później zmieniać. */
State* l ookup (char *prefi x [NPREF] , i nt create) {
i nt i , h ; State *sp; h = hash (prefi x) ; for (sp = statetab [h] ; sp ! = NULL; sp = sp->next) for (i = O ; i < NPREF; i ++) if ( strcmp {prefi x [ i ] , sp->bpref[i ] ) != O) break ; i f ( i = = NPREF) /* Znaleziono */ return s p ; i f (create) { sp = (State *) emal l oc ( s i zeof(State) ) ; for ( i = O ; i < NPREF; i ++) sp->pref [ i ] = prefi x [i ] ; sp->suf = NULL; sp->next = statetab [h] ; statetab [h] = s p ; return s p ;
Zauważmy, ż e funkcja l oo kup przy tworzeniu nowego stanu nie wykonuje kopii przycho dzących łańcuchów, tylko zapisuje wskaźniki za pomocą instrukcji sp->pre f [] . Zadaniem programisty wywołującego tę funkcję jest zagwarantowanie nienaruszalności tych danych w przy szłości. Jeśli np. łańcuchy są przechowywane w buforze strumienia wejścia-wyjścia, to przed wywołaniem funkcji l ookup należy wykonać ich kopię. Gdyby tego nie zrobiono, następna porcja danych wejściowych zniszczyłaby dane wskazywane przez elementy naszej tablicy mie szania. Często do rozstrzygnięcia pozostaje kwestia, czyją własnością są wspólne zasoby do stępne poprzez interfejs. Zagadnienie to szczegółowo omówimy w następnym rozdziale. Czas na napisanie funkcji tworzącej tablicę mieszania z danych pobieranych z pliku: I* build: wczyluje dane i tworzy tablicę przedrostków *I
voi d bui l d (char *prefi x [NPREF] , FILE *f) {
char buf[lOO] , fmt [lO] ;
/* Tworzy lmicuchformatowania; 'Yas może 1'1'.J'Wołać przepełnienie bufora */
spri ntf(fmt , "%%%ds " , si zeof{buf) - 1 ) ; whi l e (fscan f ( f , fmt , buf) ! = EOF) add ( prefi x , estrdup (buf) ) ;
Wywołanie funkcji spri ntf pozwala obejść potencjalny problem z wywołaniem funkcji scanf, która poza tym doskonale nadaje się do tego celu. Funkcja f s c a n f wywołana z forma tem %s wczytuje z pliku wejściowego do bufora kolejne słowo, nie zwracając przy tym uwagi na liczbę znaków. Istnieje zatem ryzyko poważnych kłopotów, gdyby długość łańcucha przekro czyła rozmiar bufora wejściowego. Jeśli bufor miałby rozmiar 100 bajtów (znacznie więcej niż rozmiar jakiegokolwiek wyrazu w normalnym tekście), moglibyśmy zastosować format %99s
76
3. PROJEKTOWANIE I IMPLEMENTACJA
(jeden bajt pozostawiając dla zera oznaczającego koniec łańcucha) nakazujący funkcji wczytać maksymalnie 99 bajtów danych. Przy takim podejściu bardzo długie słowa zostaną podzielone na części, ale lepsze to niż ryzykowanie kłopotów. Moglibyśmy zapisać następujące deklaracje:
enum char
BUFS I Z E = 100) ; fmt [] = "%99s " ; /* BUFSIZE-1 */
ale w takim przypadku będziemy zmuszeni do określenia rozmiaru bufora, zdefiniowania dwóch stałych i jeszcze dodatkowo zadbania o odpowiednie ich powiązanie. Problem ten można rozwiązać przez tworzenie łańcucha formatu dynamicznie za pomocą funkcji spri n t f i właśnie to podejście zastosowaliśmy w naszym programie. Funkcja bui 1 d przyjmuje dwa argumenty: tablicę prefi x przechowującą NPREF poprzed nich słów i wskaźnik na plik FI LE. Następnie przekazuje przedrostek i kopię słowa wejściowego funkcji add, która dodaje nowy wpis do tablicy mieszania i przesuwa przedrostki: /* add: dodaje słowo do listy przyrostków i aktualizuje tablicę przedrostków */
voi d add (char *prefi x [NPREF] , char *suffi x) {
State *sp; sp = l ookup (prefi x , 1) ; /* Utwórz, jeśli nie znajdziesz */ addsuffi x ( s p , suffi x) ; /* Przesunięcie słów w tablicy przedrostków */
memmov e ( prefi x , prefi x+l , (NPREF- l } *si zeof (prefi x [O] ) ) ; prefi x [NPREF-1] su ffi x ; =
Wywołanie funkcji memmove to idiomatyczny sposób usunięcia elementu z tablicy. Funkcja ta przesuwa elementy z pozycji 1-NPREF- 1 tablicy przedrostków na pozycję O-NPREF-2, przy okazji usuwając pierwsze słowo przedrostka i tworząc na końcu miejsce na nowe słowo. Natomiast procedura addsuffi x dodaje nowy przyrostek: /* addsuffix: dodaje do stanu. Przyrostek nie może się później zmienić */
voi d {
addsuffi x (State *sp, char *suffi x) Suffi x *suf; suf = (Suffi x *} emal l oc ( s i zeof(Suffi x) ) ; suf->word = suffi x ; suf->next = sp->suf; sp->suf = suf;
Czynność aktualizacji wykonujemy w dwóch funkcjach: add i addsuffi x. Funkcja add jest bardziej ogólna i jej zadaniem jest dodawanie przyrostka do przedrostka, z kolei funkcja addsuffi x wykonuje specyficzną dla programu czynność dodawania słowa do listy przyrost ków. Pierwsza z tych funkcji jest wykorzystywana przez funkcję b u i 1 d, natomiast funkcji addsuffi x używa tylko na swoje wewnętrzne potrzeby funkcja add. Mimo iż jest ona wywoły wana tylko w jednym miejscu, tego rodzaju szczegóły implementacyjne zawsze lepiej wyodręb niać w postaci osobnych funkcji, gdyż nie wiadomo, czy się nie zmienią.
3.4. GENEROWANIE TEKSTU
77
3.4. Generowanie tekstu Mamy już projekt struktury danych, a więc możemy przejść do generowania tekstu wyjściowego. Główna idea pozostaje bez zmian: weź przedrostek, wybierz losowo jeden przyrostek, wydru kuj, przesuń elementy w tablicy przedrostków. Jest to stały punkt naszego programu. Nie wie my jednak jeszcze, jak rozpoczynać i kończyć działanie algorytmu. Początek będzie łatwy, jeśli tylko zapamiętamy słowa tworzące pierwszy przedrostek i od nich zaczniemy. Zakończenie również nie jest trudne - wystarczy zdefiniować specjalne słowo, po którego napotkaniu algo rytm będzie kończył działanie. W tym celu na końcu danych wejściowych możemy umieścić takie słowo, które na pewno nie pojawi się w żadnym normalnym tekście:
bui l d (prefi x , stdi n) ; add (prefi x , NONWORD) ; Stałej NONWORD należy przypisać taką wartość, która z pewnością nie pojawi się w żadnym normalnym zbiorze danych wejściowych. Ponieważ słowa na wejściu są oddzielane spacjami lub innymi znakami białymi, do naszych celów idealnie nada się słowo utworzone z takiego właśnie znaku, np. znaku nowego wiersza:
char NONWORD []
=
11 \n11 ; /* Nie może wystąpićjako zwykle słowo */
Pozostał jeszcze jeden problem do rozwiązania: co program zrobi, gdy danych będzie za mało, aby uruchomić algorytm? Są dwie możliwości: albo zamykamy program, albo przyjmu jemy założenie, że każda porcja danych jest wystarczająca i w ogóle nie sprawdzamy jej rozmiaru. W tym programie zastosowaliśmy to drugie podejście. Proces tworzenia struktury danych i generowania tekstu możemy uruchamiać przy użyciu specjalnie spreparowanego przedrostka. W ten sposób zagwarantujemy, że program zawsze otrzyma minimalną ilość potrzebnych danych wejściowych. W celu rozruszania pętli programu zainicjalizujemy tablicę przedrostków zawierającą same słowa NONWORD. Dodatkową korzyścią z tego jest to, iż pierwsze słowo pliku wejściowego będzie pierwszym przyrostkiem sztucznego przedrostka, dzięki czemu pętla generująca będzie musiała wydrukować tylko przyrostki, które wytworzy. Aby ilość danych wyjściowych nie okazała się zbyt duża jak na nasze możliwości, możemy działanie algorytmu kończyć po wygenerowaniu określonej liczby słów lub po napotkaniu słowa NONWORD użytego jako przyrostka, zależnie od tego, co będzie pierwsze. Dodanie kilku słów NONWORD na końcach danych znacznie upraszcza kod głównych pętli przetwarzania. Jest to przykład zastosowania techniki polegającej na oznaczeniu końców da nych za pomocą tzw. wartowników (ang. sentinel). Jedną z zasad programowania jest to, aby w programie obsłużyć wszystkie nieregularności, wyjątki i specyficzne rodzaje danych. Pisanie kodu można sobie nieco ułatwić, jeśli zadba się o jak najprostszy i regularny przepływ sterowania. Funkcja generate stanowi realizację algorytmu opisanego przez nas wcześniej. Wytwarza po jednym słowie na wiersz danych wyjściowych, które można następnie połączyć w dłuższe linie przy użyciu procesora tekstu. W rozdziale 9. omawiamy prosty program o nazwie fmt, który można wykorzystać do tego celu. Dzięki użyciu słów NONWORD na początku i na końcu danych funkcja generate rozpoczyna i kończy działanie zgodnie z oczekiwaniami:
78
3. PROJEKTOWANIE I IMPLEMENTACJA
I* generale: tworzy dane wyjściowe, po jednym słowie na wiersz */
voi d generate ( i nt nwords) { State * s p ; Suffi x *suf; char *prefi x [NPREF] , *w ; i nt i , nmatch ; for (i = O ; i < NPREF; i ++) /* Wyzerowanie początkowego prefiksu *I prefi x [ i ] = NONWORD ; for (i = O; i < nwords ; i ++) sp = l ookup (prefi x , O) ; nmatch = O ; for (suf = sp->suf; s u f ! = NULL; s u f = suf->next) i f (rand ( ) % ++match == O) /* Prawdopodobieństwo = l/nmatch *I w = suf->word ; i f (strcmp (w , NONWORD) == O) break ; pri ntf ( "%s\ n " , w) ; memmove (prefi x , pre fi x+ 1 , (NPREF- 1 ) *si zeof (pre fi x [Ol) ) ; prefi x [NPREF-1] = w ;
Jeśli nie znamy liczby elementów, algorytm pobiera losowo jeden z nich. Podczas przeglą dania listy liczba elementów jest zapisywana w zmiennej nmatch. Wyrażenie
rand() % ++nmatch == O zwiększa zmienną nmatch i zwraca wartość „prawda" z prawdopodobieństwem 1/nmat ch. Zatem pierwszy element zostaje wybrany z prawdopodobieństwem 1, drugi zastąpi go z praw dopodobieństwem 1/2, trzeci zastąpi poprzedni z prawdopodobieństwem l/3 itd. W dowolnym momencie każdy z k użytych dotychczas elementów został wybrany z prawdopodobieństwem 1/k. Na początku elementom tablicy pre fi x nadaliśmy wartości początkowe, które na pewno znajdą się w tablicy mieszania. Pierwszymi słowami w zmiennej typu Suffi x będą pierwsze słowa dokumentu, ponieważ są one gwarantowanymi następnikami pierwszego przedrostka. Później przyrostki będą wybierane losowo. Pętla for wywołuje funkcję l ookup w celu znalezie nia w tablicy mieszania bieżącego przedrostka, następnie wybiera losowy przyrostek, drukuje go i przesuwa elementy tablicy przedrostków. Jeśli wybrany przyrostek jest słowem NONWORD, to kończymy działanie pętli, ponieważ oznacza ono koniec danych wejściowych. W pozostałych przypadkach drukujemy wybrane słowo, ka sujemy pierwsze słowo przedrostka za pomocą wywołania funkcji memmove, przesuwamy przy rostek na miejsce drugiego wyrazu przedrostka i powtarzamy pętlę. Teraz wszystkie napisane procedury możemy zebrać w funkcji ma i n, która wczytuje dane ze standardowego strumienia wejściowego i generuje nie więcej niż określoną liczbę słów: /* main: generuje losowe łańcuchy Markowa "'!
i nt mai n (vo i d ) { i nt i , nwords = MAXGEN; char *prefi x [NPREF] ;
/* Bieżący przedrostek wejściowy */
79
3.5. JAVA
for (i O ; i < NPREF ; i ++) prefi x [i ] NONWORD ; bui l d (prefi x , stdi n) ; add (prefi x , NONWORD} ; generate(nwords} ; return O ;
/* Początkowy przedrostek */
=
=
Oto cała implementacja w języku C. Na końcu rozdziału dokonamy jeszcze porównania implementacji programu napisanych w różnych językach programowania. Największą zaletą języka C jest to, że programista z niego korzystający ma pełną kontrolę nad tym, co pisze, i programy napisane w tym języku zwykle działają bardzo szybko. Ceną za te korzyści jest jed nak zwiększona ilość pracy, ponieważ trzeba samodzielnie przydzielać i zwalniać pamięć, two rzyć tablice mieszania oraz listy powiązane itd. Język C jest jak żyletka, za pomocą której można utworzyć elegancki i wydajny program albo się pokaleczyć.
Ćwiczenie 3.1. Do działania algorytmu wybierającego losowo elementy z listy o nieznanej dłu gości potrzebny jest dobry generator liczb losowych. Zaprojektuj go i przeprowadź kilka ekspe rymentów, aby sprawdzić, jak ten algorytm działa w praktyce.
Ćwiczenie 3.2. Gdyby słowa wejściowe przechowywano w drugiej tablicy mieszania, cały tekst byłby przechowywany tylko w jednym miejscu, co powinno umożliwić zaoszczędzenie pamię ci. Sprawdź na kilku dokumentach, jakiego rzędu to oszczędność. Stosując taką organizację, przy wybieraniu przedrostków z tablicy mieszania porównywalibyśmy wskaźniki, a nie rze czywiste słowa, co powinno przyspieszyć działanie programu. Zaimplementuj tę wersję pro gramu i sprawdź, jak różni się od poprzedniej wersji pod względem szybkości działania i zuży cia pamięci.
Ćwiczenie 3.3.
Zmodyfikuj funkcję
czyła działanie bez słów
NONWORD
generate
w taki sposób, aby poprawnie rozpoczynała i koń
w roli wartowników. Pamiętaj, że program musi prawidłowo
działać także wówczas, gdy na wejściu otrzyma zero słów, bądź dwa, trzy lub cztery słowa. Po równaj tę wersję z wersją z wartownikami.
3 . 5 . Java Drugą implementację algorytmu łańcucha Markowa napiszemy w Javie.
W
językach obiekto
wych, takich jak Java, szczególną uwagę należy poświęcić interfejsom między komponentami programu, które stanowią hermetycznie zamknięte odrębne jednostki nazywane obiektami lub klasami, wyposażone w specjalne funkcje zwane metodami. Java ma obszerniejszą bibliotekę niż język C. Można w niej znaleźć m.in.
nerowych
Vector,
szerzalnego kontenera jest klasa o nazwie
Obj ect.
zbiór klas konte
służących do grupowania obiektów na rozmaite sposoby. Jednym przykładem roz
Innym jest klasa
H a s h t a b l e,
służąca do przechowywania obiektów typu
która służy do przechowywania wartości jednego typu
i pobierania ich za pomocą kluczy w postaci obiektów innego typu. Jeśli chodzi o naszą aplikację, to do przechowywania przedrostków i przyrostków idealnie nadają się wektory (obiekty klasy
Vector)
łańcuchów. Możemy użyć obiektu klasy
Has hmap,
której klucze będą stanowiły wektory przedrostków, a wartości - wektory przyrostków. Zgodnie z tradycyjną nomenklaturą taką strukturę danych nazywamy rowującym przedrostki na przyrostki.
W
słownikiem
(ang.
Javie nie musimy tworzyć typu
map) odwzo
S tate,
ponieważ
80
3. PROJEKTOWANIE I IMPLEMENTACJA
struktura Has htabl e automatycznie wiąże przedrostki z przyrostkami. Zatem projekt w tym języku będzie się różnił od projektu w języku C, w którym musieliśmy utworzyć struktury State do przechowywania stanów składających się z przedrostków i odpowiadających im przy rostków oraz zapisywaliśmy je w tablicy mieszania według przedrostków, aby uzyskać pełne stany. Klasa Has htab l e udostępnia metodę put, służącą do zapisywania par klucz-wartość, i me todę get do pobierania wartości odpowiadającej podanemu kluczowi:
Hashtabl e h = new Hashtabl e ( ) ; h . put ( key , val ue) ; Sometype v = ( Sometype) h . get ( key) ;
W naszym programie utworzymy trzy klasy. Pierwszą z nich nazwiemy Prefi x i posłuży nam ona do przechowywania słów tworzących przedrostki: c l ass Prefi x { publ i c Vector pref;
li NPREF kolejnych słów ze strumienia wejściowego
Druga klasa, o nazwie Cha i n, będzie wczytywała dane, tworzyła tablicę mieszania i genero wała dane wyjściowe. Oto definicja jej zmiennych:
cl ass Cha i n { stat i c fi nal i nt NPREF = 2 ; llRozmiarprzedrostka stat i c f i n a l String NONWORO = " \n " ; li ,. Słowo ", które nie może pojawić się w danych wejściowych
Hashtabl e statetab = new Hashtabl e ( ) ; li klucz = przedrostek, wartość = wektor przyrostków
Prefi x pref i x = new Prefi x ( N PREF, NONWORD) ; li Przedrostek początkowy
Random rand = new Random ( ) ;
Trzecia klasa będzie interfejsem publicznym. Odgrywa ona dwie role: zawiera funkcję ma i n i tworzy egzemplarz klasy Cha i n :
c l ass Markov { stat i c fi nal i nt MAXGEN 10000; li Limit liczby wygenerowanych słów publ i c stati c voi d mai n (Stri n g [] args) throws IOExcept i on { Cha i n cha i n = new Chai n () ; i nt nwords MAXGEN ; =
=
cha i n . bu i l d ( System . i n) ; chai n . generate (nwords ) ;
Gdy utworzymy egzemplarz klasy Cha i n, obiekt ten utworzy tablicę mieszania i ustawi po czątkowy przedrostek złożony z NPREF słów NONWORD. Do podziału danych wejściowych na po jedyncze słowa według rozdzielających je białych znaków służy funkcja biblioteczna o nazwie StreamTo keni zer. Trzy wywołania znajdujące się przed pętlą przestawiają algorytm dzielenia tekstu na tryb rozpoznawania słów zgodnie z naszą definicją.
81
3.5. JAVA
li Metoda build z klasy Chain: tworzy tablicę stanów z danych pobieranych na wejściu voi d bui l d ( I nputStream i n) throws IOExcepti on {
StreamToken i zer st = new StreamTokeni zer ( i n) ; s t . resetSyntax () ; li Usuwa domyślne reguły s t . wordChars ( O , Character . MAX VALUE) ; li Włącza wszystkie znaki s t . whi tespaceChars ( O , ' ' ) ; li oprócz spacji whi l e ( s t . nextToken () ! = s t . TT_EOF) add (st . sval ) ; add (NONWORD) ; -
Funkcja add pobiera z tablicy mieszania wektor przyrostków odpowiadających bieżącemu przedrostkowi. Jeśli go nie ma, tworzy nowy wektor i prefiks, które zapisuje w tablicy. W każ dym przypadku funkcja add dodaje nowe słowo do wektora przyrostków i aktualizuje przedro stek, usuwając jego pierwsze słowo i dodając nowe słowo na jego końcu.
li Metoda add z klasy Chain: dodaje słowo do listy przyrostków i aktualizuje przedrostek v o i d add (Stri ng word) { Vector suf = (Vector) stateta b . get (prefi x) ; i f (suf == nul l ) { suf = new Vector () ; statetab . put(new Pref i x ( prefi x) , suf) ; } s u f . addEl ement (word ) ; prefi x . pref. removeEl ementAt (O) ; pref i x . pref. addEl ement(word) ;
Zauważmy, że jeśli wektor s u f jest pusty, funkcja add wstawia do tablicy mieszania nowy obiekt klasy Pre fi x, a nie wektor pre fi x. Jest to konieczne z tego względu, iż w klasie Hashtabl e zapisywane są tylko referencje do elementów, a więc jeśli nie sporządzimy kopii wektora, to w przyszłości moglibyśmy utracić dane z tablicy. Z takim samym problemem mieliśmy do czynienia w trakcie pisania programu w języku C. Funkcja generująca jest w Javie podobna do odpowiednika w języku C. Będzie tylko nieco krótsza, ponieważ może odwoływać się do dowolnego elementu wektora bezpośrednio za po mocą indeksów, zamiast używać pętli do przeglądania listy.
li Metoda generale z klasy Chain: generuje tekst wyjściowy voi d generate ( i nt nwords) { prefi x = new Prefi x (NPREF, NONWORD) ; for ( i nt i = O ; i < nword s ; i ++) Vector s (Vector) statetab . get (prefi x) ; i nt r = Math . abs (rand . next i n t ( ) ) % s . si z e ( ) ; Stri ng suf = (Stri ng) s . el ementAt ( r) ; i f (suf. equal s (NONWORD) ) brea k ; System . out . pri ntl n (suf) ; prefi x . pref. removeEl ementAt (O) ; prefi x . pref . addEl ement (suf) ; =
82
3. PROJEKTOWANIE I IMPLEMENTACJA
W klasie Prefi x zdefiniowaliśmy dwa konstruktory tworzące egzemplarze tej klasy z do starczonych danych. Pierwszy kopiuje istniejący obiekt Pre fi x, a drugi tworzy przedrostek z n kopii łańcucha. Służy nam on przy inicjalizacji tablicy do utworzenia NPREF kopii słowa NONWORD:
li Konstruktor klasy Prejix: kopiuje istniejący przedrostek Prefi x ( Prefi x p) {
pref = (Vector) p . pref . c l one ( ) ;
liKonstrnktor klasy Prefix: tworzy n kopii ła11cucha str Prefi x ( i nt n , Stri ng str) { pref = new Vecto r ( ) ; for ( i n t i = O; i < n ; i ++) pref. addEl ement (str) ;
Klasa Prefi x ma również dwie metody, o nazwach h a s hCode i equal s . Są one niejawnie wykorzystywane przez obiekt klasy Hashtab l e do indeksowania. i przeszukiwania tablicy mie szania. Konieczność posiadania tych dwóch metod, z których korzysta klasa Hashtab l e, wy musiła na nas zdefiniowanie klasy Pre fi x dla przedrostków. Gdyby nie to, moglibyśmy użyć zwykłego wektora, tak jak w przypadku przyrostków. Metoda hashCode oblicza wartości mieszania dla wszystkich elementów wektora, a następ nie łączy je w jedną wartość dla całego przedrostka:
stati c fi nal i nt MULT I P L I ER = 3 1 ;
li Mnożnik dla metody hashCodeO
li Metoda hashCode z klasy Prejix: genernje wartość mieszania z wszystkich słów tworzących przedrostek publ i c i nt hashCod e ( ) { i nt h = O ; for ( i n t i = O ; i < pref . s i ze { ) ; i ++) h = MULTI PLIER * h + pref . e l ementAt ( i ) . hashCode ( ) ; return h ;
Natomiast metoda equal s porównuje elementy przedrostków, aby sprawdzić, czy zawierają takie same słowa:
li Metoda equals z klasy Prejix: sprawdza, czy w porównywanych prefiksach znajdują się takie same słowa publ i c bool ean equal s (Obj ect o) { Prefi x p ( Prefi x) o ; for ( i nt i = O ; i < pref . s i z e ( ) ; i ++) i f ( ! pref . el ementAt (i ) . equal s ( p . pref . el ementAt ( i ) ) ) return fal se; return true;
3.6. c++
83
Program w Javie jest znacznie krótszy od odpowiednika w C, a przy tym zadbano w nim o więcej szczegółów. W znacznym stopniu umożliwiły nam to klasy Vector i Hashtab l e. Ogólnie rzecz biorąc, w Javie łatwiej jest zarządzać pamięcią, ponieważ wektor automatycznie zmienia rozmiar, jeśli zajdzie taka potrzeba, a system usuwania nieużytków uwalnia nas od konieczno ści pamiętania o zwalnianiu nieużywanych fragmentów pamięci. Nie wszystko jednak robi za nas język programowania, gdyż klasie Hashtab l e do działania potrzebne są metody h a s hCode i equa l s, które musieliśmy napisać samodzielnie. Jeśli porównamy sposób reprezentacji struktur danych i operowania na nich w obu języ kach, to spostrzeżemy, że w Javie lepiej rozdzieliliśmy poszczególne zadania. Na przykład za miana wektorów na tablice nie sprawiłaby nam żadnego problemu. Jednak w języku C każda część programu wie, co robią pozostałe części - tablica mieszania operuje na rozmieszczonych w różnych miejscach tablicach, funkcja l oo kup zna budowę struktur State i Suffi x, a rozmiar tablicy przedrostków jest znany każdej funkcji.
% j ava Markov
Ćwiczenie 3.4. Zmień program napisany w Javie tak, aby w klasie State do przechowywania przedrostków zamiast wektora używana była tablica.
3.6. c + + Trzecią wersję programu zaimplementujemy w języku C+ + . Ze względu na podobieństwo między językami c i c + + kod w języku c + + często można traktować jako kod w c zawiera jący kilka usprawnień notacyjnych. Także pierwsza wersja programu napisana w języku C jest poprawnym programem w języku C + + . Lepiej byśmy jednak zrobili, gdybyśmy do budowy programu w języku C + + użyli klas i obiektów, podobnie jak w Javie. To pozwoliłoby nam ukryć szczegóły implementacyjne. My poszliśmy nawet jeszcze o krok dalej i użyliśmy stan dardowej biblioteki wzorców, czyli tzw. biblioteki STL (ang. Standard Template Library), która zawiera wiele potrzebnych nam narzędzi. W standardzie ISO biblioteka STL stanowi część definicji języka C + + . W bibliotece STL można znaleźć różne kontenery, takie jak wektory, listy i zbiory, oraz ze staw podstawowych algorytmów do przeszukiwania, sortowania, usuwania i dodawania ele mentów. Dzięki temu, że biblioteka STL powstała w oparciu o szablony języka C + + , jej algo rytmy współpracują z różnymi kontenerami, wliczając w to zarówno te zdefiniowane przez użytkownika, jak i kolekcje typów wbudowanych, takich jak i nt. Kontenery te mają postać ogólnych szablonów, które można dostosowywać na potrzeby przechowywania obiektów do wolnego typu. Istnieje przykładowo klasa o nazwie vector, na bazie której można utworzyć kontenery do przechowywania m.in. liczb całkowitych (vector) i łańcuchów znaków (vector). W tych konkretnych wersjach dostępne są wszystkie standardowe operacje klasy v ector, włącznie z algorytmami sortowania.
84
3. PROJEKTOWANIE I IMPLEMENTACJA
Oprócz klasy vector, która jest podobna do kontenera Vector w Javie, biblioteka STL za wiera dodatkowo kontener o nazwie deque (należy wymawiać: dek). Jest to kolejka dwukie runkowa, świetnie nadająca się do tego, co robimy z przedrostkami: pozwala przechowywać dowolną liczbę elementów oraz udostępnia operacje pobierania elementu z początku i wsta wiania elementu na końcu o stałej złożoności czasowej. Klasa deque dostępna w bibliotece STL jest nawet bardziej ogólna, niż nam potrzeba, gdyż umożliwia zdejmowanie i wkładanie elementów z obu stron, ale gwarantowana przez nią wydajność sprawia, że nie ma co się zasta nawiać nad jej wyborem. W bibliotece STL dostępny jest też słownik w postaci klasy o nazwie map. Jest to struktura danych działająca na zasadzie drzew zrównoważonych, która pozwala przechowywać pary klucz-wartość i oferuje czas dostępu do wartości skojarzonych z kluczami na poziomie O(logn). Słowniki może nie dorównują wydajnością tablicom mieszania, które oferują stały czas dostępu do elementów, ale i tak przyjemnie jest pomyśleć, że nie trzeba nic pisać, aby móc z nich ko rzystać (w niektórych niestandardowych implementacjach języka c + + znajdują się klasy hash i hash_map mogące oferować nawet jeszcze lepszą wydajność). Do porównywania składników przedrostków użyjemy wbudowanej funkcji porównywania. Z tymi narzędziami pod ręką bez problemu napiszemy kod programu. Oto pierwsze dekla racje:
typedef deque Prefi x ; map > stateta b ; liprzedrostek -> przyrostki
W bibliotece STL znajduje się szablon klasy deque, który można wyspecjalizować do prze chowywania łańcuchów, stosując zapis deque. Ponieważ struktury tej używamy w progra mie wielokrotnie, nadaliśmy jej nazwę Prefi x za pomocą instrukcji typedef. Natomiast słowniko wi, w którym będziemy przechowywać przedrostki i przyrostki, nie nadawaliśmy żadnej nowej nazwy, gdyż zostanie on użyty tylko w jednym miejscu. Zdefiniowaliśmy z kolei zmienną statetab reprezentującą słownik kojarzący przedrostki z wektorami łańcuchów. Ta metoda jest wygod niejsza niż techniki, które zastosowaliśmy zarówno w C, jak i w Javie, gdyż zwalnia nas z obo wiązku dostarczania funkcji mieszającej lub metody equa 1 s. Funkcja ma i n inicjalizuje przedrostek, wczytuje dane wejściowe ( z wejścia standardowego, które w bibliotece i ostream języka c + + nazywa się c i n), dołącza ogon i generuje dane wyj ściowe, dokładnie tak jak poprzednie wersje programu: li main: funkcja generiifąca tekstprzy użyciu algorytmu la1icucha Markowa
i nt mai n (voi d) { i nt nwords MAXGEN ; Prefi x prefi x ; =
li Bieżący przedrostek wejściowy
for ( i nt i = O ; i < NPREF; i ++) llPoczątkowyprzedrostek add (pref i x , NONWORD) ; bui l d (prefi x , ci n) ; add (prefi x , NONWORD) ; generate (nwords) ; return O ;
Funkcja bui 1 d wczytuje z wejścia p o jednym słowie, wykorzystując do tego celu narzędzia z biblioteki i ostream:
85
3.6. c + +
li build: pobiera slowa z wejścia i tworzy tablicę stanów
voi d bui l d ( Prefi x& prefi x , i stream& i n ) { stri ng buf; whi l e ( i n >> buf) add ( prefi x , buf} ;
Długość słów na wejściu jest nieograniczona, gdyż zmienna łańcuchowa b u f może się w razie potrzeby powiększać. Na przykładzie użycia funkcji add można spostrzec wiele zalet używania biblioteki STL: li add: dodaje slowo do listy przyrostków, aktualizuje przedrostek
voi d add ( Prefi x& pref i x , const stri ng& s) { i f (prefi x . s i ze ( ) ;; N PREF) { statetab [prefi x] . push-bac k ( s ) ; prefi x . pop_front (} ; ) prefi x . push_bac k ( s ) ;
Te pozornie proste instrukcje kryją sporo tajemnic. W klasie map operator indeksowa nia [] jest przeciążony w taki sposób, że zachowuje się jak operacje wyszukiwania. Wyrażenie statetab [pre fi x] przeszukuje słownik statetab przy użyciu klucza pre fi x i zwraca referen cję do znalezionego elementu. Jeśli dany wektor nie istnieje, to zostaje utworzony. Funkcja push_back, która wchodzi w skład zarówno klasy vector, jak i deque, dołącza nowy łańcuch na końcu struktury danych. Natomiast funkcja pop_front zdejmuje pierwszy element z listy deque. Algorytm tworzenia tekstu wyjściowego jest podobny do tego z poprzednich wersji: li generale: tworzy dane wyjściowe, pojednym słowie na wiersz
voi d generate ( i nt nwords) { Pref i x prefi x ; i nt i ; for ( i ; O ; i < NPREF; i ++) llPoczątkowyprzedrostek add (prefi x , NONWORD) ; for (i O ; i < nword s ; i ++} vector& suf ; statetab[prefi x] ; const stri ng& w ; suf [rand ( ) % suf . s i ze ( } ] ; i f (w NONWORD) break ; cout << w << " \n " ; prefi x . pop front ( } ; li Przesunięcie prefi x . pu sh_back (w} ; ;
;;
86
3. PROJEKTOWANIE I IMPLEMENTACJA
Ogólnie rzecz biorąc, ta wersja programu jest wyjątkowo elegancka i przejrzysta - zwięzły kod, widoczna struktura danych oraz jasny algorytm. Niestety, ma to swoją cenę, ponieważ ten program działa znacznie wolniej od wersji w języku C, ale i tak nie jest najwolniejszy ze wszyst kich. Do pomiarów wydajności niedługo wrócimy.
Ćwiczenie 3.5. Największą zaletą biblioteki STL jest to, że umożliwia eksperymentowanie z róż nymi strukturami danych. Zmień w przedstawionym wyżej programie struktury danych użyte do reprezentowania przedrostków, przyrostków i tablicy stanów na dowolne inne, aby spraw dzić, jak zmieni się wydajność programu.
Ćwiczenie 3.6. Napisz program w języku C + + przy użyciu tylko klas i typu danych stri ng. Nie korzystaj z żadnych dodatkowych zaawansowanych narzędzi bibliotecznych. Porównaj swój program z wersją STL pod względem stylu i szybkości działania.
3.7. Awk i Perl Na zakończenie przedstawiamy jeszcze wersje programu napisane w dwóch popularnych języ kach skryptowych: Awku i Perlu. Języki te udostępniają wszystko, czego nam potrzeba, czyli tablice asocjacyjne i mechanizmy przetwarzania łańcuchów.
Tablica asocjacyjna (ang. associative array) to w istocie tablica mieszania w poręcznym opako waniu. Na pierwszy rzut oka przypomina zwykłą tablicę, ale indeksami tablicy asocjacyjnej mogą być dowolne ciągi znaków i liczby, a także ich listy oddzielane przecinkami. Są one ro dzajem słowników odwzorowujących jeden typ danych w inny. W języku Awk dostępne są tyl ko tablice asocjacyjne, podczas gdy w Perlu można korzystać zarówno z konwencjonalnych ta blic indeksowanych liczbami całkowitymi, jak i tablic asocjacyjnych, tu nazywanych tablicami mieszania ze względu na to, jak są zaimplementowane. Programy w Awku i Perlu zoptymalizujemy tylko pod kątem przedrostków złożonych z dwóch słów. # markov.awk: alg01ytm lmicucha Markowa dla przedrostków dwuwyrazowych
BEGIN { MAXGEN = 10000 ; NONWORD "\n" ; wl = w2 = NONWORD } { for (i = 1 ; i <= N F; i ++) { # Wczytanie wszystkich słów statetab [wl , w2 , ++nsuffi x [wl ,w2] ] = $ i wl w2 w2 $ i =
= =
END { statetab [wl , w2 , ++ns uffi x [w l , w2] ] = NONWORD # Dodanie ogona wl = w2 = NONWORD for (i O; i < MAXGEN ; i ++) { # Generowanie r i nt (ran d ( ) *nsuffi x [w l , w2] ) + 1 # nsujfix >= 1 p = statetab [wl , w2 , r] i f (p == NONWORD) exi t pri nt p # Aktualizacja lmicucha wl w2 w2 = p =
=
=
87
3.7. AWK I PERL
Program w języku Awk to sekwencja instrukcji zdefiniowanych dla określonych wzorców, tzn. program wczytuje dane po jednym wierszu, każdy wiersz porównuje ze zdefiniowanymi wzorcami i dla każdego wiersza odpowiadającego wzorcowi wykonuje odpowiednie działania. Istnieją też dwa wzorce specjalne: BEG I N i END. Zgodność z pierwszym z nich uzyskuje się przed pierwszym wierszem danych wejściowych, a z drugim - po ostatnim wierszu. Czynność definiuje się jako blok instrukcji w nawiasach klamrowych. W naszym progra mie blok BEG I N inicjalizuje kilka zmiennych, w tym także zmienną przechowującą przedrostek. Ponieważ następny blok nie ma żadnego wzorca, zostanie on domyślnie wykonany dla każ dego wiersza danych wejściowych. Język Awk automatycznie rozbija każdy wiersz na tzw. pola (słowa oddzielane spacjami) o nazwach od $ 1 do $ N F. Zmienna N F określa liczbę pól. Instrukcja
statetab [wl , w2 ,++nsuffi x [w l , w2] ]
=
$i
buduje słownik odwzorowujący przedrostek na przyrostki. Tablica nsuffi x służy do liczenia przyrostków, a element tablicy ns uffi x [wl , w2] zawiera liczbę przyrostków skojarzonych z danym przedrostkiem. Przyrostki są przechowywane w elementach tablicowych statetab [wl , w2 , 1] , statetab [wl ,w2 , 2] itd. Wykonanie bloku END oznacza, że nastąpił koniec danych wejściowych. W tym momencie dla każdego przedrostka istnieje element tablicy n s uffi x zawierający licznik przyrostków od powiadających temu przedrostkowi oraz tyle elementów tablicy statetab z przyrostkami, ile wskazuje ten licznik. Wersja programu w Perlu jest bardzo podobna do poprzedniej, z tą różnicą, że zamiast trzeciego indeksu do liczenia przyrostków została użyta anonimowa tablica. Ponadto aktualiza cja przedrostka jest wykonywana przy użyciu instrukcji wielokrotnego przypisania. W języku Perl do oznaczania typów zmiennych używa się różnych specjalnych znaków, tak więc $ ozna cza wartości skalarne, @ tablice indeksowane liczbami, do których elementów można się odwoływać za pomocą nawiasów kwadratowych [] , a klamry { } służą do indeksowania tablic mieszania. -
# markov.pl: alg01ytm łańcucha Markowa dla przedrostków dwuwyrazowych
$MAXGEN = 10000 ; $NONWORD = " \n " ; $wl = $w2 = $NONWORD; # Stan początkowy whi l e (<>) { # Wczytywanie wszystkich wierszy danych wejściowych foreach (spl i t) { push ( @ { $statetab { $wl } { $w2 } } , $ ) ; ($wl , $w2) = ($w2 , $_) ; # Wielokrotne przypisanie } pus h ( @ { $statetab { $wl } { $w2 } } , $NONWORD) ;
# Dodanie ogona
$wl $w2 = $NONWORD ; O; $ i < $MAXGEN ; $ i ++) for ( $ i $suf = $statetab { $wl } { $w2 } ; # Odwalanie do tablicy $r = i nt ( rand @$s uf) ; # @$suf oznacza liczbę elementów exi t i f ( ($t = $suf->[$r] ) eq $NONWORD) ; pri nt "$t\n" ; ($wl , $w2) = ($w2 , $t) ; # Aktualizacja laiicucha =
=
Tak jak w poprzednich programach słownik zapisaliśmy przy użyciu zmienńej statetab. Sercem programu jest poniższy wiersz:
88
3. PROJEKTOWANIE I IMPLEMENTACJA
push (@{ $ statetab { $wl } { $w2 } } , $_) ; Umieszcza on nowy przyrostek na końcu anonimowej tablicy zapisanej w elemencie
statetab { $w1 } { $w2 } . Podczas generowania danych wyjściowych instrukcja statetab { $wl } '-+ { $w2 } jest odwołaniem do tablicy przyrostków, natomiast $ s u f - > [ $ r] wskazuje przyrostek o numerze r. Kod źródłowy programów w Perlu i Awku jest bardziej zwięzły niż w pozostałych prezen towanych językach, ale za to trudniej jest go przystosować do działania z przedrostkami skła dającymi się z innej liczby słów niż dwa. Główna część (funkcje add i generate) programu napisa nego przy użyciu biblioteki STL języka C+ + ma podobną długość, a przy tym jest bardziej przejrzysta. Niemniej jednak języki skryptowe często doskonale nadają się do eksperymento wania, tworzenia prototypów, a nawet pisania programów użytkowych, w których szybkość działania nie jest najważniejszym czynnikiem.
Ćwiczenie 3.7. Dostosuj programy napisane w Awku i Perlu, aby obsługiwały przedrostki do wolnej długości. Sprawdź, jak te zmiany wpłynęły na wydajność programu.
3.8. Wydajność Mamy do porównania kilka implementacji tego samego programu. Do mierzenia ich szybko ści działania wykorzystaliśmy Księgę Psalmów z angielskiej wersji Biblii króla Jakuba, która zawiera 42 685 słów (5 238 słów niepowtarzających się i 22 482 przedrostki). W tekście wystę puje na tyle dużo powtarzających się fraz, np. „Blessed is the . . . ", że jedna z list przyrostków zawierała aż ponad 400 elementów. Oprócz tego było kilkaset list zawierających po kilka dziesiąt przyrostków. Można zatem stwierdzić, że wybraliśmy dobry zestaw danych testowych.
Bl essed i s the man of the net . Turn thee unto me , and rai se me u p , that I may tel l al l my fears . They l ooked unto h i m , he heard . My pra i s e shal l be bl essed . Weal th and ri ches shal l be saved . Thou hast deal t wel l wi th thy hi d treasure: they are cast i nto a stand i ng water, the fl i nt i nto a stand i ng water, and dry ground i nto waterspri ngs . W poniższej tabeli przedstawione są wyniki pomiarów czasu potrzebnego do wygenerowa nia 1 0 tysięcy słów przez każdy z programów na dwóch komputerach. Pierwszy z nich miał procesor MIPS R lOOOO 250 MHz i system operacyjny Irix 6.4, a drugi: procesor Pentium II 400 MHz, 128 MB pamięci RAM oraz system operacyjny Windows NT. Czas wykonywania programu prawie całkowicie zależy od rozmiaru danych wejściowych, gdyż proces generowania wyniku jest bardzo szybki. W tabeli uwzględniono również przybliżoną liczbę wierszy kodu źródłowego, z jakiej składa się każdy program. 250 MHz
c
Java C+ +/STL/deque C+ +/STL/list Awk Perl
400 MHz
RIO OOO
Pentium II
Liczba wierszy kodu
0,36 s 4,9 2,6 1,7 2,2
0,30 s 9,2 1 1,2 1,5 2,1
150 105 70 70 20
1,8
1,0
18
3.9. WNIOSKI
89
D o kompilacji programów w językach C i C+ + użyto kompilatorów optymalizujących, na tomiast program w Javie był uruchomiony przy włączonej kompilacji na czas. W przypadku programów w C i C + + uruchomionych w systemie Irix wybrano najlepsze czasy uzyskane z trzech różnych kompilatorów. Podobne wyniki zostały uzyskane także w maszynach Sun SPARC i DEC Alpha. Program napisany w języku C zdeklasował wszystkie pozostałe pod względem szybkości działania. Na drugim miejscu jest implementacja w Perlu. Należy jednak zaznaczyć, że wyniki przedstawione w tabeli pokazują tylko doświadczenia zebrane przez nas przy korzystaniu z określonego zestawu bibliotek i kompilatorów. Gdyby ktoś inny przepro wadził te same testy, mógłby otrzymać zupełnie inne wyniki. W systemie Windows jest coś nie tak z implementacją kolekcji deque z biblioteki STL języka C+ + . Z naszych analiz wynikało, że operacje związane z tą kolekcją, która jest wykorzystywa na do reprezentacji przedrostków, dominowały w ogólnym czasie wykonywania programu, a przecież nie zawiera ona nigdy więcej niż dwa elementy. Należałoby się spodziewać, że dominująca będzie główna struktura danych, czyli słownik. Zamiana na listę dwukierunkową z STL poprawiła wynik wielokrotnie. Natomiast zmiana słownika na niestandardową tablicę mieszania w systemie Irix nie przyniosła żadnego rezultatu. W naszym systemie Windows nie mieliśmy dostępu do tablic mieszania. To że do zamiany jednej kolekcji na inną wystarczyło tylko zamienić słowo 1 i st na deque, hash albo map w tylko dwóch miejscach, jest ogromną za letą biblioteki STL. Na podstawie doświadczeń stwierdzamy, że biblioteka STL, która stanowi nowość w języku C+ +, jest jeszcze nie w pełni dopracowana. Nie da się przewidzieć zmian wydajności programu, jeśli użyje się innej implementacji, a nawet gdy zastosuje się różne struktury danych. To samo dotyczy Javy, w której również występują duże różnice między im plementacjami. Testowanie programów służących do wytwarzania dużej ilości losowych danych to nie lada wyzwanie. Skąd wiadomo, że program w ogóle działa? Skąd wiadomo, czy program pracuje przez cały czas? W rozdziale 6„ poświęconym testowaniu, przedstawiamy kilka propozycji oraz opisujemy, jak testowaliśmy programy Markowa.
3.9. Wnioski Program Markowa ma długą historię. Jego pierwszą wersję napisał Don P. Mitchell, a później w latach 80. Bruce Ellis zaadaptował ją do użytku w zabawach dekonstrukcyjnych. Potem o programie zapomniano na dłuższy czas, aż wygrzebaliśmy go w celu wykorzystania na zaję ciach uniwersyteckich do zademonstrowania etapów projektowania programu. Nie użyliśmy jednak oryginału, lecz na jego podstawie napisaliśmy całkiem nową wersję w języku C, aby przypomnieć sobie, jakie problemy trzeba rozwiązać podczas implementacji tego algorytmu. Następnie opracowaliśmy jeszcze kilka dodatkowych wersji w różnych innych językach pro gramowania, za każdym razem używając specyficznych idiomów charakterystycznych dla da nego języka. Po serii wykładów przerabialiśmy programy wielokrotnie, aby poprawić przejrzy stość ich kodu źródłowego. Jednak przez cały ten czas podstawowy projekt pozostawał niezmieniony. Rozwiązania za stosowane przez nas zostały użyte także w pierwotnej wersji programu, aczkolwiek w nim po jawiła się jeszcze dodatkowa tablica mieszania do reprezentowania poszczególnych słów. Gdy byśmy mieli go napisać jeszcze raz, to zapewne wprowadzilibyśmy niewiele poprawek. Cały projekt programu opiera się na strukturze przetwarzanych danych. Struktury danych nie defi niują wszystkich szczegółów, ale wpływają na ogólny kształt programu. Wybór niektórych struktur danych, np. list zamiast rozszerzalnych tablic, ma niewielkie znaczenie. Pewne implementacje są bardziej ogólne od innych, np. programy w językach Awk i Perl
90
3. PROJEKTOWANIE I IMPLEMENTACJA
można by z łatwością przerobić, aby obsługiwały przedrostki jedno- lub trójwyrazowe, ale za programowanie eleganckiej implementacji takiego rozwiązania przy użyciu parametrów byłoby już trudniejsze. Jak przystało na obiektowe języki programowania, takie jak Java i c+ + , wprowadzając kilka drobnych zmian, można b y dostosować struktury danych do obsługi obiektów innego typu niż tekst w języku angielskim, np. programów (w których znaczenie miałyby białe znaki), nut, a nawet kliknięć myszą czy wyborów z menu. Podczas gdy struktury danych używane w programach różnią się między sobą w niewiel kim stopniu, to jeśli chodzi o wygląd ogólny, ilość kodu i wydajność, różnice między nimi są duże. Ogólnie rzecz biorąc, programy napisane przy użyciu języków wysokiego poziomu są wolniejsze od napisanych przy użyciu języków niskiego poziomu, ale nie należy z tego wycią gać zbyt daleko idących wniosków. Dostęp do takich narzędzi jak biblioteka STL w języku C+ + czy tablice asocjacyjne i mechanizmy przetwarzania tekstu w językach skryptowych po zwala uzyskać bardziej zwięzły kod i skrócić czas pracy nad programem. I chociaż nie ma nic za darmo, straty wydajności w takich programach jak implementacja algorytmu Markowa, które działają tylko przez kilka sekund, mogą mieć niewielkie znaczenie. Trudniej natomiast zdecydować, jak traktować utratę kontroli nad programem, gdy system, w którym działa, dostarcza takich ilości kodu, że w zasadzie nie wiadomo, co tak naprawdę się tam dzieje. Właśnie ten problem dotyczy biblioteki STL. Jej wydajność jest zawsze wielką niewiadomą i nie ma dobrego sposobu, aby ją jakoś oszacować. Jedna z implementacji bibliote ki STL wymagała poprawek, zanim w ogóle mogliśmy jej użyć do uruchomienia naszego pro gramu. Niewielu programistów ma możliwości i siły do znajdowania i poprawiania takich nie dociągnięć. Problem ten cały czas narasta i trzeba się z nim borykać coraz częściej : im biblioteki, in terfejsy i inne narzędzia stają się bardziej skomplikowane, tym trudniej je ogarnąć i nad nimi zapanować. Gdy wszystko idzie dobrze, to rozbudowane środowiska programistyczne stanowią ogromną pomoc dla programisty, ale jeśli tylko wystąpi jakaś trudność, nie ma gdzie szukać pomocy. Jeżeli w programie wystąpią trudne do wykrycia usterki związane z wydajnością lub logiką, to możemy sobie przez długi czas nie zdawać sprawy z ich istnienia. Na podstawie projektu i implementacji przedstawionego w tym rozdziale programu można wyciągnąć kilka ogólnych wniosków, które dotyczą także większych aplikacji. Po pierwsze zawsze należy wybierać jak najprostsze struktury danych, wystarczające do rozwiązania zada nego problemu w rozsądnym czasie. Jeśli ktoś już wcześniej coś takiego robił i zamieścił w bi bliotece odpowiednie rozwiązania, to jeszcze lepiej. Skorzystaliśmy z takiej pomocy podczas programowania implementacji w języku c+ + . Kierując się radą Brooksa: naszym zdaniem projektowanie programu najlepiej jest zacząć od szczegółowego opracowania struktury danych, biorąc jednocześnie pod uwagę to, jakie algo rytmy będą z nią najlepiej współpracować. Gdy dysponuje się ułożonymi strukturami danych, pisanie programu jest o wiele łatwiejsze. Trudno od razu stworzyć idealny projekt programu i potem wcielić go w życie. Zazwyczaj praca nad programem odbywa się na zasadzie serii prób i błędów. W trakcie pisania kodu zmu szani jesteśmy do szczegółowego objaśnienia tych zagadnień, nad którymi wcześniej nie zasta nawialiśmy się zbyt wiele. Tak też przebiegała praca nad programami przedstawionymi w tym rozdziale. Wielokrotnie zmienialiśmy w nich rozmaite szczegóły. Jeśli to możliwe, zawsze za czynaj pracę od czegoś prostego, aby następnie poszerzając swoją wiedzę, stopniowo dodawać kolejne elementy. Gdybyśmy algorytm Markowa chcieli zaprogramować wyłącznie na własne potrzeby, to prawie na pewno użylibyśmy do tego celu języka Awk lub Perl, aczkolwiek nie po święcilibyśmy tak dużo uwagi szczegółom. Jednak napisanie programu, który będzie rozpowszechniany wśród użytkowników, to znacznie trudniejsze zadanie niż utworzenie prototypu. Gdybyśmy programy przedstawione w tym roz dziale chcieli traktować jako nadające się do powszechnego użytku (bo je przetestowaliśmy
LEKTURA UZUPEŁNIAJĄCA
91
i dopracowaliśmy), to stwierdzilibyśmy, że napisanie takiego programu może wymagać nawet o dwa rzędy wielkości więcej wysiłku niż napisanie go na własny użytek.
Ćwiczenie 3.8. Zetknęliśmy się z implementacjami programu Markowa w wielu językach pro gramowania, takich jak Scheme, Tel, Prolog, Python, ogólna Java, ML i Haskell. Każdy z nich miał swoje zalety i przedstawiał specyficzne trudności, z którymi trzeba było sobie poradzić. Napisz ten program w swoim ulubionym języku i porównaj jego wydajność i ogólne cechy z imple mentacjami przedstawionymi w tej książce.
Lektura uzupełniająca Opis biblioteki STL można znaleźć w wielu książkach, między innymi w książce pt. Generic Programming and the STL Matthew Austerna (Addison-Wesley, 1 998). Wyczerpującym źró dłem wiedzy o języku C+ + jest książka Bjarne'a Stroustrupa pt. Język C + + (WNT, 2002). Po informacje o Javie warto sięgnąć do książki Java TM Kena Arnolda i Jamesa Goslinga (WNT, 1 999). Najlepszy opis języka Perl znajduje się w książce Programming Perl Larry'ego Walla, Toma Christiansena i Randala Schwartza (O'Reilly, 1 996). Idea wzorców projektowych (ang. design pattems) opiera się na spostrzeżeniu, że w więk szości programów używanych jest tylko kilka podstawowych konstrukcji, podobnie jak jest tylko kilka bazowych struktur danych. Wzorce projektowe można luźno porównać do idiomów kodu, które zostały opisane w rozdziale 1. Klasycznym podręcznikiem na ten temat jest książ ka pt. Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku Ericha Gammy, Richarda Helma, Ralpha Johnsona i Johna M. Vlissidesa (Helion, 20 1 0). Barwne dzieje niesfornego programu markov, który pierwotnie miał nazwę shaney, zostały opisane w artykule pt. Computing Recreations w czerwcowym numerze czasopisma „Scientific American" z 1989 roku. Artykuł ten opublikowano ponownie w książce pt. The Magie Machine A.K. Dewdneya (W.H. Freeman, 1990).
92
3. PROJEKTOWANIE I IMPLEMENTACJA
Interfejsy
Zanim mur wybuduję, powinienem wiedzieć, Od czego się odgradzam, co zamurowuję I komu czynię afront przez stawianie muru. Istnieje siła murom granicznym przeciwna, Która pragnie je zburzyć.
Robert Frost, Naprawianie muru (przeł. L. Elektorowicz, Wiersze, Warszawa, PIW 1 972)
Istotą projektowania jest znalezienie równowagi między celami a ograniczeniami. Przy pisaniu niewielkiego samowystarczalnego systemu programista może sobie pozwolić na pewne ustęp stwa, ponieważ konsekwencje podejmowanych przez niego decyzji są widoczne tylko w tym systemie i dotyczą wyłącznie jego samego. Jeśli jednak z programu będzie korzystać szersze grono odbiorców, skutki podjętych decyzji mogą być dalece bardziej doniosłe. Przystępując do pracy nad projektem, należy zawsze rozważyć następujące kwestie: •
Interfejsy: jakie usługi planujemy oferować i jak będzie można uzyskać do nich dostęp? Istotną cechą interfejsu jest to, że stanowi on rodzaj umowy wiążącej dostawcę i odbiorcę. Należy dążyć do tego, aby oferowane usługi były spójne i wygodne w użyciu, a także funkcjonalne, lecz nie nazbyt rozbudowane, aby nie sprawiały kłopotów użytkownikowi.
•
Ukrywanie informacji: jakie informacje ukryjemy, a jakie pozostawimy widoczne? Kon strukcja interfejsu powinna pozwalać na bezproblemowy dostęp do jego składników i jed nocześnie ukrywać ich szczegóły implementacyjne, tak aby można było je modyfikować bez wiedzy użytkownika.
•
Zarządzanie zasobami: kto zarządza pamięcią i innymi ograniczonymi zasobami? Naj ważniejsze problemy w tym przypadku to: przydzielanie i zwalnianie pamięci oraz obsłu ga wspólnych kopii informacji.
•
Obsługa błędów: kto wykrywa błędy, a kto je zgłasza i w jaki sposób to robi? Jakie środki zaradcze są stosowane na wypadek pojawienia się błędu?
94
4. INTERFEJSY
W rozdziale 2. zajmowaliśmy się poszczególnymi składnikami budowy systemu, czyli strukturami danych. W rozdziale 3. z połączenia tych struktur utworzyliśmy niewielki pro gram. Teraz natomiast zajmiemy się sposobami łączenia komponentów programowych mogą cych pochodzić z różnych źródeł. Budowę interfejsu przedstawimy na przykładzie projektu biblioteki funkcji i struktur danych pomocnych w wykonywaniu pewnego typowego zadania. Przy okazji zdefiniujemy kilka podstawowych zasad projektowania. W przeciętnym projekcie do podjęcia są setki decyzji, ale tylko niewielka część z nich jest dokonywana świadomie. In terfejsy tworzone z pominięciem omawianych tu zasad są często dosyć chaotyczne, o czym wie każdy programista, który miał nieprzyjemność zmagać się z nimi w swojej pracy.
4. 1 . Wartości oddzielane przecinkami Format CSV, czyli wartości oddzielane przecinkami (ang. comma-separated values), to powszechnie stosowany standardowy format do prezentacji danych tabelarycznych. Wiersze tabeli są repre zentowane jako linie tekstu, a poszczególne pola oddzielają przecinki. Początek tabeli z końca poprzedniego rozdziału w formacie CSV można by przedstawić następująco: , "250 MHz " , " 400 MHz " , " Li czba wi erszy " , "Rl OOOO " , " Penti um I I " , " kodu" C , 0 . 36 s , 0 . 30 s , 150 Java , 4 . 9 , 9 . 2 , 105 Formatu tego używają do prezentacji i pobierania danych rozmaite programy, np. arkusze kalkulacyjne. Nie jest też dziełem przypadku to, że w tym formacie pojawiają się różne usługi na stronach internetowych, np. informacje o cenach akcji. Na pewnej popularnej stronie inter netowej zawierającej kursy akcji można znaleźć taką tabelę: Symbol
Last Trade
Exchange
Volume
LU
2 : 19 PM
86-1/4
+4-1/16
+4,94%
5 804 8 0 0
T
2:19 PM
60-l l/16
-1-3/16
-1,92%
2 468 ooo
MSFT
2:24 PM
106-9/16
+ 1-3/8
+ 1,31 %
1 1 474 900
Download Spreadsheet Format
Pobieranie takich danych za pomocą przeglądarki internetowej jest możliwe, ale czaso chłonne. Trzeba uruchomić aplikację, poczekać, obejrzeć serię reklam, wpisać listę akcji, cze kać, czekać, czekać, obejrzeć kolejną serię reklam i w końcu można otrzymać dane. Strasznie żmudne zajęcie. Aby dalej przetworzyć te liczby, należy wykonać jeszcze kilka dodatkowych czynności. Klikając odnośnik Download Spreadsheet Format (Pobierz plik arkusza kalkulacyjnego) albo podobny, pobierzemy na dysk komputera plik w formacie CSV zawierający dane ułożone w mniej więcej następujący sposób (układ danych został trochę zmodyfikowany, aby zmieścił się na stronie): " LU " , 86 . 25 , " 1 1/4/1998" , " 2 : 19PM" , +4 . 0625 , 83 . 9375 , 86 . 87 5 , 83 . 625 , 5804800 "T" , 60 . 6875 , " l l/4/1998" , "2 : 19 PM" , - 1 . 1875 , 62 . 375 , 62 . 625 , 60 . 43 7 5 , 2468000 "MSFT " , 106 . 562 5 , " 1 1/4/1998" , " 2 : 24PM" , + 1 . 375 , 105 . 8125 , 107 . 3 1 2 5 , 105 . 5625 , 1 1474900
95
4.2. PROTOTYP BIBLIOTEKI
W tym przykładzie w oczy rzuca się złamanie zasady, zgodnie z którą tego rodzaju prace powinno się zlecać do wykonania komputerowi. Wprawdzie przeglądarki pozwalają uzyskać dostęp do danych umieszczonych na serwerze, ale wygodniej byłoby pobierać informacje au tomatycznie. Za kliknięciami tych wszystkich przycisków kryją się w istocie czysto tekstowe procedury: przeglądarka wczytuje kod HTML, użytkownik wpisuje na stronie tekst, przeglą darka przekazuje go do serwera i w zamian znowu otrzymuje kod HTML. Tego rodzaju infor macje można z łatwością pobierać automatycznie, jeśli tylko dysponuje się odpowiednimi narzę dziami i zna właściwy język programowania. Poniżej przedstawiamy program napisany w języku Tel łączący się z opisywanym serwisem internetowym w celu pobrania informacji o kursach akcji w formacie CSV z kilkoma nagłówkami na początku: # getquotes.tcl: ceny akcji form Lucent, A T&T i Microsoft
set so [soc ket quot e . yahoo . com 80] ; # Połączenie z serwerem set q "/d/quotes . csv?s= LU+T+MSFT&f=sl ldltlclohg v " p u t s $so "GET $q HTTP/1 . 0\n\n " fl ush $so puts [read $so]
; # Wysianie żądania ; # Pobranie i wydrukowanie odpowiedzi
Zagadkowy łańcuch zaczynający się od znaków f= to nieudokumentowany łańcuch sterujący, analogiczny do pierwszego argumentu funkcji p r i ntf, określający wartości, które mają zostać pobrane. Metodą prób i błędów ustaliliśmy, że s oznacza symbol akcji, 1 1 ostatnią cenę, c 1 zmianę w stosunku do poprzedniego dnia itd. Nie o szczegóły nam tu jednak chodzi, które i tak mogą się zmieniać, lecz o samą możliwość zautomatyzowania tego procesu. Pobranie i prze konwertowanie na odpowiedni format potrzebnych informacji można wykonać całkowicie bez angażowania człowieka. Wszystko zrobi za nas maszyna. Program getquotes uwinie się z pracą w ułamku sekundy, podczas gdy nam zajęłoby to znacznie więcej czasu. Dane po pobraniu można poddać dalszemu procesowi przetwarzania te w formatach takich jak CSV najłatwiej przetwarzać, gdy ma się pod ręką specjalne biblioteki służące do ich pobierania i generowania, najlepiej połączone jeszcze z narzędziami do ich ob róbki, np. konwersji liczb. Ponieważ nie znamy ani jednej ogólnodostępnej biblioteki przezna czonej do przetwarzania danych w formacie CSV, napiszemy ją samodzielnie. W kilku następnych podrozdziałach przedstawimy trzy wersje biblioteki do wczytywania danych w formacie CSV i ich przekształcania na format wewnętrzny programu. Przy okazji omówimy różne problemy, jakie może napotkać programista projektujący oprogramowanie, które musi współpracować z innym oprogramowaniem. Nie istnieje np. oficjalna specyfikacja formatu CSV, przez co w swojej pracy nie możemy oprzeć się na żadnych ścisłych regułach. Tego typu kwestie często pojawiają się podczas projektowania interfejsów.
4.2. Prototyp biblioteki Istnieje niewielkie prawdopodobieństwo, że już za pierwszym razem uda nam się napisać do skonały projekt biblioteki lub interfejsu. Jak napisał kiedyś Fred Brooks, „zaplanuj, że odrzu cisz jeden projekt, bo na pewno będziesz musiał". Brooks pisał o dużych systemach, ale jego spostrzeżenia można odnieść do każdego większego programu. Nierzadko jest tak, że różne kwestie można zrozumieć na tyle dobrze, aby poprawnie zaprojektować system, dopiero po utworzeniu i używaniu przez jakiś czas jego pierwszej wersji.
96
4. INTERFEJSY
W związku z tym konstrukcję naszej biblioteki do przetwarzania danych w formacie CSV rozpoczniemy od utworzenia prototypu, który później odrzucimy. W pierwszej wersji projektu pominiemy wiele zagadnień, którymi zajęlibyśmy się, gdybyśmy do zadania podchodzili bar dziej starannie. Mimo to biblioteka będzie nadawała się do użytku, co pozwoli nam dokładniej zapoznać się z problemem. Pracę zaczniemy od napisania funkcji o nazwie es vget l i ne - będzie ona pobierała z pliku po jednym wierszu danych CSV do bufora wejściowego, dzieliła je na pola, które zapisze w ta blicy, usuwała cudzysłowy i zwracała wartość określającą liczbę pól. W naszej praktyce mieliśmy okazję pisać podobny program w prawie wszystkich językach programowania, jakie znamy, więc nie jest to dla nas nowość. Oto prototypowa wersja biblioteki napisana w języku C. Doda liśmy znaki zapytania, aby zaznaczyć, że to tylko wersja próbna: char buf [200] ; char *fi el d [20] ;
/* Bufor danych wejściowych */ /* Pola */
I* csvgetline: wczytuje i przetwarza wiersze danych. zwraca licznik pól */ I* Przykładowa porcja danych wejściowych: "LU",86.25, "111411998", "2:19PM", +4. 0625 *I
i nt csvget l i ne (F I L E *fi n) {
i nt nfi el d ; char *p , *q ; i f ( fgets (buf, s i zeof(buf) , fi n ) == NULL) return - 1 ; nfi el d = O ; for {q = buf; {p=strto k { q , " , \n\r" ) ) ! = NULL; q fi el d [nfi el d++] = unquote {p) ; return nfi el d ;
NULL)
W początkowej części programu znajduje się komentarz zawierający przykładową porcję danych wejściowych dla tego programu. Takie komentarze ułatwiają programistom zrozumie nie działania programów pobierających dane w skomplikowanych formatach. Ponieważ format CSV jest zbyt skomplikowany, aby zapisane w nim dane wygodnie pobie rać za pomocą funkcji scanf, posłużyliśmy się standardową funkcją języka C o nazwie s trtok. Wywołanie funkcji st rto k ( p, s) zwraca wskaźnik na pierwszy leksem występujący w argu mencie p, w którym nie ma znaków występujących w argumencie s . W celu oznaczenia końca leksemu funkcja strtok zamienia następny znak oryginalnego łańcucha na pusty bajt. W pierw szym wywołaniu pierwszy argument funkcji strtok wskazuje łańcuch, który ma zostać prze analizowany. W kolejnych wywołaniach analizowanie jest wznawiane od oznaczonego symbo lem NULL miejsca, w którym zostało uprzednio przerwane. Jest to bardzo niskiej jakości interfejs. Między poszczególnymi wywołaniami funkcji strtok przechowywana jest sekretna zmienna, dlatego w jednym czasie może być aktywna tylko jedna sekwencja wywołań. Niepo wiązane naprzemienne wywołania funkcji będą ze sobą kolidować. Funkcja unquote usuwa cudzysłowy z początku i końca przykładowego łańcucha danych. Ponieważ jednak nie radzi sobie z cudzysłowami w środku danych, nie nadaje się do ogólnych zastosowań, aczkolwiek w prototypie jest wystarczająca. /* unquote: usuwa cudzysłowy z początku i kaika danych */
char *unquote {char *p) { i f {p [O] == ) { ""
97
4.2. PROTOTYP BIBLIOTEKI
i f ( p [strl en (p) -1] == ' " ' ) p [strl en (p) - 1] = ' \O ' ; p++ ; return p ;
D o sprawdzenia, czy funkcja csvgetl i ne działa, posłuży nam prosty program testowy: /* Funkcja main programu csvtest: testujefunkcję csvgetline */
i nt rnai n (voi d) { i nt i , nf; whi l e ( (nf = csvgetl i ne (std i n ) ) != - 1 ) for ( i = O ; i < nf; i ++) pri ntf ( " fi el d [%d] '%s ' \n " , i , fi el d [i ] ) ; return O ; =
Funkcja p r i n t f drukuje pola w pojedynczych cudzysłowach, które stanowią ograniczniki, a przy okazji pozwalają wykryć błędy obsługi spacji. Możemy teraz uruchomić ten program na danych zwróconych przez program getquotes.tcl: % getquotes . tc l I csvtest fi el d [O] fi el d [l] fi el d [2] fi el d [3] fi el d [4] fi el d [5] fi el d [6] fi e l d [7] fi e l d [8] fi el d [O] fi el d [l]
' LU ' ' 86 . 37 5 ' ' 1 1/5/1998 ' ' l : Ol PM ' ' -0 . 125 ' ' 86 ' ' 86 . 375 ' ' 85 . 0625 ' ' 2888600 ' 'T' ' 6 1 . 0625 '
(Usunęliśmy nagłówki HTTP). Wygląda na to, że nasz prototyp prawidłowo przetwarza dane takiego typu, jak powyższe. Dla pewności warto jednak przetestować go jeszcze na jakichś innych danych, zwłaszcza jeśli mamy zamiar udostępnić nasz program do użytku komuś innemu. Znaleźliśmy kolejny serwis internetowy zawierający informacje o kursach akcji, lecz prezentujący je w nieco zmienionej formie. Zamiast znaku nowego wiersza do oddzielania rekordów użyto w nim znaku powrotu karetki (\ r) przy czym koniec pliku nie jest oznaczany tym znakiem. Poniższe dane przereda gowaliśmy, aby zmieściły się na stronie książki: ,
"Ti cker" , " Pri ce " , " Change" , " Open " , " Prev Cl ose " , " Day Hi gh" , " Day Low" , "52 Week H i gh " , "52 Week Low" , "D i v i dend " , " Y i e l d " , " Vol urne " , "Average Vol urne " , " P/ E " " LU " , 86 . 3 1 3 , -0 . 188 , 86 . 00 0 , 86 . 500 , 86 . 43 8 , 85 . 063 , 108 . 50 , 36 . 18 , 0 . 16 , 0 . 1 , 2946700 , 9675000 , N/A "T" , 61 . 125 , 0 . 938 , 60 . 37 5 , 60 . 188 , 61 . 12 5 , 60 . 000 , 68 . 50 ,
98
"
4. INTERFEJSY
46 . 50 , 1 . 32 , 2 . 1 , 3061000, 4777000 , 17 . 0 MSFT 107 . OOO , 1 . 500 , 105 . 3 1 3 , 105 . 500 , 107 . 188 , 105 . 250, 1 19 . 62 , 59 . 0 0 , N/A , N/A , 7977300 , 16965000 , 51 . 0 " ,
Na tych danych wejściowych nasz prototyp poległ. Popełniliśmy zasadniczy błąd polegający na tym, że do tworzenia prototypu przystąpiliśmy od razu po przeanalizowaniu danych z tylko jednego źródła i początkowo przetestowaliśmy go tylko na danych z tego samego źródła. Nie należy się zatem dziwić, że przy pierwszym spotka niu z danymi pobranymi z innego miejsca ponieśliśmy sromotną klęskę. Naszemu programowi poważnych trudności nastręczają długie wiersze danych wejściowych, duże ilości pól oraz nie przewidziane znaki oddzielające bądź ich całkowity brak. Tego wrażliwego na przeciwności losu prototypu można używać tylko na własne potrzeby albo posługiwać się nim jako dowodem na to, że zadanie jest wykonalne, ale to wszystko. Zanim przystąpimy do pisania nowej imple mentacji, powinniśmy jeszcze raz przemyśleć nasz projekt. Projektując prototyp, dokonaliśmy wielu wyborów. Pewne z nich były świadome, a inne zupełnie nieświadome. Oto niektóre z decyzji, które podjęliśmy. Nie wszystkie należy naśla dować przy projektowaniu biblioteki ogólnego przeznaczenia. Każda z tych decyzji sygnalizuje jakiś problem, którym trzeba się bliżej zająć. •
Prototyp nie obsługuje długich wierszy wejściowych i dużych ilości pól. Wyniki zwracane przez program mogą być nieprawidłowe, ponieważ nie ma w nim mechanizmu zabezpieczają cego przed przepełnieniami, a nawet procedury zapewniającej sensowne wartości zwrotne w przypadku wystąpienia błędów.
•
Na wejściu program oczekuje danych w postaci wierszy znaków oddzielanych znakami nowego wiersza.
•
Pola, które są ujmowane w pojedyncze cudzysłowy, rozdzielane są przecinkami. Nie przewidziano możliwości wystąpienia cudzysłowów ani przecinków wewnątrz samych danych.
•
Po pobraniu wiersz danych wejściowych nie jest przechowywany, lecz kasowany przez procedurę tworzenia pól.
•
Między jednym wierszem danych wejściowych a kolejnym nie są zapamiętywane żadne informacje. Jeśli trzeba coś zapamiętać, konieczne jest wykonanie kopii tego czegoś.
•
Tablicę, służącą do przechowywania pól, reprezentuje zmienna globalna o nazwie f i e l d, do której mają wspólny dostęp funkcja csvgetl i ne i funkcje ją wywołujące. Dostęp do treści pól i wskaźników nie jest w żaden sposób kontrolowany. Nie ma też zabezpieczenia przed sięganiem poza ostatnie pole.
•
Zmienne globalne uniemożliwiają równoległe wykonywanie wątków programu, a nawet przeplatanie wykonywania dwóch sekwencji wywołań.
•
Funkcja cs vgetl i ne pobiera dane tylko z otwartych plików, co oznacza, że użytkownik musi je jawnie otwierać.
•
Operacje pobierania danych i dzielenia ich na pola są nierozerwalnie połączone. W każdym wywołaniu wiersz danych zostaje wczytany i podzielony na pola, bez względu na to, czy jest to aplikacji potrzebne, czy nie.
•
Wartością zwrotną jest liczba pól w wierszu. Aby obliczyć tę wartość, trzeba podzielić każdy wiersz. Ponadto nie istnieje sposób na odróżnienie błędów spowodowanych napo tkaniem końca pliku. Żadnej z powyższych cech programu nie da się zmienić bez modyfikacji kodu.
•
4.3. BIBLIOTEKA DLA INNYCH
99
W przedstawionych wyżej punktach wypisaliśmy niektóre z licznych trudności projekto wych, z jakimi przyjdzie nam się zmierzyć. Każda decyzja, którą podjęliśmy, ma swoje bezpo średnie odbicie w kodzie. Takie podejście można stosować w prostych zadaniach, takich jak np. przekształcanie z jednego formatu na inny, niezmienny format, danych pochodzących ze znanego źródła. Co się jednak stanie, jeżeli format się zmieni, między cudzysłowami pojawi się przecinek albo serwer przekaże wyjątkowo długi wiersz, tzn. nietypowo dużą liczbę pól? Wydaje się, że nie są to problemy trudne do rozwiązania, zwłaszcza iż biblioteka jest nie wielka, a poza tym to i tak tylko prototyp. Wyobraź sobie jednak, że po miesiącach lub latach nieużywania program wraca do łask i zostaje wcielony do większego programu, którego specy fikacja zmienia się, zanim zostanie on ukończony. Jak funkcja scvgetl i ne zareaguje na te zmiany? Jeśli program, o którym mowa, byłby używany przez innych ludzi, podejmowanie w pośpiechu decyzji przy jego budowie może się nam odbić czkawką dopiero po latach. Tego rodzaju pro blemy spotykał już w przeszłości niejeden źle napisany interfejs. Przykro to mówić, ale na prędce sklecony i pełen usterek kod często trafia do powszechnie używanego oprogramowania, gdzie pozostaje niezmieniony przez wiele lat i ciągnie się jak kula u nogi.
4.3. Biblioteka dla innych Korzystając z doświadczenia zebranego przy budowie prototypu, spróbujemy napisać bibliote kę nadającą się do użytku ogólnego. Najbardziej oczywistą rzeczą jest to, że musimy poprawić funkcję cs vget l i ne tak, aby obsługiwała długie wiersze składające się z dużej liczby pól. Także procedura analizy pól wymaga ulepszenia. Jeśli chcemy, aby nasz interfejs nadawał się do użytku przez innych ludzi, przy tworzeniu jego projektu musimy przemyśleć zagadnienia wymienione na początku rozdziału: interfejsy, ukrywanie informacji, zarządzanie zasobami i obsługę błędów. Współpraca tych wszystkich elementów ma bardzo duży wpływ na ostateczny kształt programu. Przedstawiony podział jest jednak nieco sztuczny, gdyż wszystkie wymienione kwestie są ze sobą wzajemnie powiązane.
Interfejs. Zdecydowaliśmy się udostępniać trzy podstawowe operacje: char *csvgetl i n e ( FI LE *) : wczytuje nowy wiersz danych w formacie CSV, char *csvfi el d ( i nt n) : zwraca n-te pole bieżącego wiersza, i nt csvnfi el d ( voi d) : zwraca licznik pól znajdujących się w bieżącym wierszu. Jaką wartość powinna zwracać funkcja csvgetl i n e ? Najlepiej, gdyby zwracała jak najwięcej potrzebnych informacji, co przywodzi na myśl liczbę pól, którą zwracała także wersja prototy powa. Wówczas jednak pola byłyby liczone nawet wtedy, gdyby nie były używane. Inną moż liwością jest zwracanie długości wiersza wejściowego, która zależy od tego, czy znajdujący się na końcu znak nowego wiersza zostanie uwzględniony, czy nie. Po kilku eksperymentach zde cydowaliśmy, że funkcja csvgetl i ne będzie zwracała wskaźnik na oryginalny wiersz wejściowy albo wartość NULL, jeśli napotka koniec pliku. Znak nowego wiersza z końca wiersza zwracanego przez funkcję usuniemy, ponieważ w ra zie potrzeby można go łatwo przywrócić. Z definicją pola będą problemy. Staraliśmy się opracować definicję, która odpowiadałaby takim danym, jakie można znaleźć w arkuszach kalkulacyjnych i innych programach. Pole to ciąg dowolnej liczby znaków (włącznie z zerem). Do oddzielania pól służą przecinki. Białe znaki przed i za polem pozostają zachowane. Jeśli pole jest ujęte w podwójne cudzysłowy, wów czas może zawierać przecinki, a także cudzysłowy, ale te drugie tylko pod warunkiem, że do
1 00
4. INTERFEJSY
ich reprezentacji zostaną użyte dwa sąsiadujące ze sobą znaki cudzysłowu. W związku z tym pole CSV " x " " y " definiuje łańcuch znaków x"y. Pola mogą być puste - puste pole 11 11 jest równoważne z polem między dwoma sąsiadującymi przecinkami. Numeracja pól zaczyna się od zera. Co będzie, jeśli użytkownik poprosi o nieistniejące pole, np. za pomocą wywołania cs vfi e 1 d ( - 1 ) albo csvfi el d ( 100000) ? W odpowiedzi na takie zapy tanie moglibyśmy zwracać puste pole " 11 , które można wydrukować i użyć w operacji porów nywania. Programy przetwarzające różne ilości pól nie musiałyby stosować żadnych specjalnych zabezpieczeń przed nieistniejącymi polami. Ale wykorzystując to podejście, uniemożliwiamy rozróżnienie braku pola od pola pustego. Innym rozwiązaniem może być wydrukowanie ko munikatu o błędzie albo nawet przerwanie pracy programu. Wkrótce wyjaśnimy, dlaczego to rozwiązanie nie jest pożądane. Zdecydowaliśmy się na zwracanie wartości NULL, która standar dowo służy w języku C do zaznaczania braku łańcuchów.
Ukrywanie informacji. Długość wiersza i liczba pól będą w naszej bibliotece nieograniczone. Aby to było możliwe, ktoś musi dostarczyć odpowiedniej ilości pamięci: wywołujący lub wy woływany (biblioteka). Funkcji fgets z biblioteki języka C przekazujemy tablicę i liczbę okre ślającą jej maksymalny rozmiar. Jeśli na wejściu pojawi się wiersz przekraczający rozmiar bufora, to zostanie on podzielony na części. W interfejsie CSV taki sposó.b działania nie jest pożądany, dlatego w razie potrzeby biblioteka będzie przydzielała dodatkową pamięć. Zatem wszystkie operacje związane z zarządzaniem pamięcią zostaną ukryte w funkcji csv getl i ne. Nic na ten temat nie wydostaje się na zewnątrz. Najlepszym sposobem n a zapewnie nie takiej izolacji jest użycie interfejsu składającego się z trzech funkcji. Funkcja cs vgetl i ne wczytuje po kolei wiersze danych, nie zważając na ich rozmiar. Funkcja csvfi el d ( n ) zwraca wskaźnik na bajty n-tego pola bieżącego wiersza, a funkcja csvnfi e 1 d - licznik pól w bieżą cym wierszu. Gdy na wejściu pojawią się dłuższe wiersze lub większe ilości pól, będziemy zmuszeni przy dzielać dodatkowe zasoby pamięci. Sposób zaprogramowania tego rozwiązania będzie ukryty w trzech wymienionych funkcjach csv. W żadnej innej części programu nie będzie wiadomo, czy biblioteka początkowo wykorzystuje małe tablice, które następnie powiększa, czy przeciw nie - używa od razu bardzo dużych tablic albo stosuje jakieś jeszcze inne rozwiązanie. Także moment zwalniania pamięci nie jest przez interfejs ujawniany. Gdy wywołana zostanie tylko funkcja cs vget 1 i ne, nie trzeba dzielić wiersza wejściowego na pola. Podziału na pola można dokonywać na żądanie. Kolejnym ukrytym szczegółem im plementacyjnym jest to, czy dzielenie wiersza na pola jest wykonywane ochoczo (natychmiast po wczytaniu wiersza danych), leniwie (tylko gdy potrzebujemy pól lub ich liczby), czy wręcz bardzo leniwie (wydzielenie tylko jednego wybranego pola).
Zarządzanie zasobami. Musimy zdecydować, kto będzie zarządzał wspólnymi informacjami. Czy funkcja csvgetl i ne powinna zwracać oryginalne dane, czy kopię danych? Postanowili śmy, że funkcja csvgetl i ne będzie zwracać wskaźnik na oryginalne dane, które zostaną skaso wane z chwilą wczytania nowego wiersza. Pola będą wydobywane z kopii wiersza wejściowego, a funkcja es vget 1 i ne zwróci wskaźnik na pole wewnątrz tego wiersza. Przy takim sposobie działania funkcji użytkownik chcąc zapisać lub zmienić wybrany wiersz lub pole, będzie musiał wykonać jego kolejną kopię, a ponadto do jego obowiązków należy zwolnienie później pamięci, której już nie będzie używał. Kto będzie otwierał i zamykał plik z danymi? Ktokolwiek to będzie, musi również zadbać o jego zamknięcie w odpowiednim czasie - czynności uzupełniające się należy wykonywać na tym samym poziomie lub w tym samym miejscu. Założymy, że funkcja c s vgetl i ne będzie wywoływana przy użyciu wskaźnika FI LE na otwarty plik oraz że zamknięcie pliku będzie na leżało do wywołującego po zakończeniu pracy.
101
4.3. BIBLIOTEKA DLA INNYCH
Zawsze trudno jest zarządzać zasobami wspólnymi lub przekazywanymi między biblioteką a mechanizmami, które z niej korzystają. Często istnieje kilka wykluczających się rozwiązań i każde z nich ma dobre uzasadnienie. Problemy i nieporozumienia związane z zasadami wspól nego korzystania z zasobów są częstym źródłem błędów.
Obsługa błędów. Ponieważ funkcja csvgetl i ne zwraca wartość NULL, nie da się w prosty spo sób odróżnić końca pliku od błędów typu wyczerpanie pamięci. Podobnie próba użycia nieist niejącego pola kończy się błędem. Moglibyśmy do naszego interfejsu dodać funkcję o nazwie csvgeterror, która - tak jak funkcja ferror - zwracałaby informacje o ostatnim błędzie, ale nie zrobimy tego, by nie komplikować kodu. Zgodnie z ogólnymi zasadami funkcje biblioteczne nie powinny w razie wystąpienia błędu ograniczać się do przerwania działania, lecz muszą przekazywać wywołującemu odpowiednie informacje, niezbędne do podjęcia przez niego właściwych dalszych czynności. Nie powinny też wyświetlać komunikatów ani żadnych wyskakujących okienek, gdyż w niektórych środowi skach takie zachowanie może być szkodliwe. Obsługa błędów to temat zasługujący na szersze potraktowanie, dlatego dyskusję tę wznowimy nieco dalej w tym rozdziale.
Specyfikacja. Wszystkie podjęte decyzje należy zebrać w jednym miejscu w celu sporządzenia dokumentacji działania funkcji cs vget l i ne i świadczonych przez nią usług. W przypadku du żych projektów specyfikację pisze się wcześniej, niż opracowuje implementację programu, gdyż pisaniem specyfikacji i pisaniem kodu zwykle zajmują się całkiem inne osoby, czasami nawet z różnych organizacji. Często jednak w praktyce jest tak, że specyfikacja i kod są rozwijane równocześnie, a nawet zdarza się, iż specyfikację pisze się po zakończeniu pracy nad kodem, aby udokumentować z grubsza, co on robi. Najlepiej sporządzanie specyfikacji rozpocząć na wczesnym etapie prac i ciągle ją udosko nalać w miarę postępu pracy nad projektem i poszerzania swojej wiedzy praktycznej. Im bar dziej precyzyjna i lepiej dopracowana będzie specyfikacja, tym większą mamy szansę, że napi szemy dobrze działający program. Nawet jeśli tworzymy tylko na własne potrzeby, warto opracować w miarę dokładną specyfikację, gdyż będzie to stanowić dla nas bodziec do rozwa żenia różnych alternatywnych rozwi;izań oraz umożliwi wgląd w podejmowane wcześniej decyzje. W specyfikacji sporządzonej na nasze potrzeby zamieścimy prototypy funkcji oraz szczegółowe opisy ich zachowań, zakresu odpowiedzialności i założeń przyjętych podczas ich pisania: Pola są oddzielane przecinkami. Pole może być ujęte w podwójny cudzysłów:
11• " . . .
Pole ujęte w podwójny cudzysłów może zawierać przecinki, ale nie znaki nowego wiersza. Pole ujęte w podwójny cudzysłów może zawierać znaki podwójnego cudzysłowu reprezentowane przez dwa znaki takiego cudzysłowu: 11 11 •
Pola mogą być puste: zarówno pole
11 11
,
jak i pusty łańcuch to reprezentacje pustego pola.
Białe znaki na początku i końcu pozostają zachowane.
char *csvget l i ne ( Fl LE *f) ; Wczytuje jeden wiersz danych z otwartego pliku f; do oznaczenia końca wiersza powinien być użyty jeden z następujących znaków: \r, \n, \r\n lub EOF. Zwraca wskaźnik na wiersz, po uprzednim usunięciu znaku końcowego lub NULL w przypadku napotkania końca pliku.
1 02
4. INTERFEJSY
Długość wiersza jest nieograniczona. W przypadku wyczerpania pamięci zwracana jest wartość NULL. Wiersze należy traktować jako pamięć tylko do odczytu; jeśli wywołujący chce zachować lub zmienić treść wiersza, musi wykonać jego kopię.
char *csvfi el d ( i nt n) ; Numeracja pól zaczyna się od O. Funkcja zwraca n-te pole ostatniego wiersza wczytanego przez funkcję csvgetl i ne; zwraca NULL, jeśli n jest mniejsze od zera lub ma wartość przekraczającą liczbę pól. Pola są oddzielane przecinkami. Pola mogą być ujęte w cudzysłowy " . . . ", które zostaną usunięte; 11 ciągi 11 11 są zamieniane na znak 11 , a przecinki nie są traktowane między znakami 11 jako znaki oddzielające. • • •
W polach nieujętych w cudzysłów znaki cudzysłowu są traktowane jak zwykłe znaki. Liczba i długość pól są nieograniczone; w przypadku wyczerpania pamięci funkcja zwraca wartość NULL. Pola należy traktować jako pamięć tylko do odczytu; aby dokonać zmian lub je zapisać, wywołujący musi wykonać kopię treści pola. Zachowanie funkcji w przypadku wywołania jej przed funkcją csvgetl i ne jest niezdefiniowane.
i nt csvnfi el d (voi d ) ; Zwraca liczbę pól w ostatnim wierszu wczytanym przez funkcję csvgetl i ne. Zachowanie funkcji w przypadku wywołania jej przed funkcją csvgetl i ne jest niezdefiniowane. Ta specyfikacja nie zawiera odpowiedzi na wszystkie pytania. Na przykład nie wiadomo, jakie wartości powinny zwracać funkcje csvfi el d i csvnfi el d, jeśli zostaną wywołane po na potkaniu przez funkcję csvget l i ne końca pliku. Jak powinny być obsługiwane nieprawidłowo zbudowane pola? Rozwiązanie wszystkich tego typu kwestii może nastręczać wielu problemów nawet w małym programie, nie mówiąc już o większych projektach, ale koniecznie trzeba spróbować. Wiele niedociągnięć i zaniedbań można wykryć dopiero podczas pracy nad imple mentacją. W dalszej części tego rozdziału przedstawiamy nową implementację, która będzie zgodna z napisaną specyfikacją. Bibliotekę podzieliliśmy na dwa pliki: nagłówek o nazwie csv.h zawie rający deklaracje funkcji stanowiących interfejs publiczny i plik csv.c z właściwym kodem im plementacji. Użytkownicy dołączają plik csv.h do swojego kodu źródłowego, a następnie kom pilują i konsolidują własne pliki z plikiem csv.c. Plik źródłowy nie musi być widoczny. Oto zawartość pliku nagłówkowego: I* csv.h: inteifejs biblioteki csv *I
extern char *csvgetl i ne ( F I LE *f) ; extern char *csvfi el d ( i nt n) ; extern i nt csvnfi el d (voi d) ;
I* Wczytuje następny wiersz */ /* Zwraca pole n */ /* Zwraca licznik pól */
1 03
4.3. BIBLIOTEKA DLA INNYCH
Zmienne wewnętrzne służące do przechowywania tekstu i funkcje takie jak spl i t są wi doczne tylko w obrębie pliku, w którym zostały zadeklarowane przy użyciu słowa kluczowego stat i c. Jest to najprostszy sposób ukrywania informacji w języku C.
enum { NOMEM = -2 } ; stat i c stati c stati c stat i c stat i c stat i c
char char i nt char i nt i nt
*l i ne *sl i ne maxl i ne **fi el d maxfi el d nfi el d =
/* Sygnał braku pamięci */
NULL; /* Znaki wejściowe */ NULL; /* Kopia wiersza używana przezfunkcję split */ /* Rozmiar tablic line[] i sline[} *I O; NULL; /* Wskaźniki na pola *I /* Rozmiar tablicy field[} */ O; /* Liczba pól w tablicyfield[] *I O;
stat i c char fi el dsep [] =
"
, ; l* Znald oddzieldjącepola *I "
Zmienne są również inicjalizowane statycznie. Wartości początkowe służą do sprawdzenia, czy utworzyć lub powiększyć tablice. Powyższe deklaracje stanowią definicję prostej struktury danych. W tablicy l i ne przecho wywany jest pobrany z wejścia wiersz. Tablica s l i ne powstaje przez skopiowanie znaków z tablicy l i ne i wstawienie znaku oznaczającego koniec każdego z pól. Tablica array zawiera wskaźniki na elementy tablicy s l i ne. Na poniższym rysunku widać stan tych trzech tablic po zakończeniu przetwarzania wiersza ab , 11 cd 11 , 11 e 11 11 f11 „ 11 9 , h 11 • Pozycje zaciemnione w tablicy s l i ne nie są częścią żadnego pola.
l i ne
s l i ne
fi e l d
b \O "
o
d \O \O "
1
h \O
2
3
Oto kod źródłowy funkcji csvgetl i ne: I* csvgetline: pobiera jeden wiersz, zwiększa tablicę w razie potrzeby */ I* Przykładowe dane wejściowe: "LU",86.25, "1 11411998", "2: 19PM", +4. 0625 */
char *csvget l i n e ( FI LE *fin) { i nt i , c ; char *newl , *news ; i f ( l i ne == NULL) { /* Alokacja przy pierwszym wywołaniu */ maxl i ne = maxfi el d = l ; l i ne = (char *) mal l oc (maxl i ne) ; sl i ne = { char *) mal l oc (maxl i ne) ; fi el d = (char **} mal l oc (maxfi e l d*si zeof ( fi el d [O] ) } ; i f ( l i ne == NULL 1 1 s l i ne == NULL 1 1 fi el d == NULL) { reset ( ) ; /* Wyczerpanie pamięci */ return NULL ;
4
1 04
4 . INTERFEJSY
for ( i =O ; (c=getc (fi n ) ) ! =EOF && ! endofl i ne ( fi n , c) ; i ++) { i f ( i >= maxl i ne-1) { /* Powiększenie wiersza */ maxl i ne *= 2 ; /* Podwojenie aktualnego rozmiaru */ newl = (char *) real l oc ( l i ne , maxl i ne) ; news = (char *) real l oc (s l i ne , maxl i ne) ; i f (newl == NULL I I news ==NULL) { reset () ; return NULL; /* Wyczerpanie pamięci */ 1 i ne = newl ; s l i ne = news ; } l i ne [i ] = c ;
l i ne [i ] = ' \O ' ; i f {spl i t () == NOMEM) reset () ; return NULL; } return (c == EOF &&
I* Wyczerpanie pamięci */
O)
NULL : l i ne ;
Wiersze przychodzące są gromadzone w tablicy l i ne, której rozmiar w razie potrzeby zostaje podwojony za pomocą funkcji rea 1 1 oc, tak jak robiliśmy w podrozdziale 2.6. Rozmiar tablicy sl i ne jest zawsze taki sam jak tablicy l i ne. Funkcja csvget l i ne wywołuje funkcję spl i t w celu utworzenia wskaźników na pola zapisywane w osobnej tablicy o nazwie fi el d, której rozmiar również w razie potrzeby może być powiększany. Zgodnie z naszym zwyczajem tablicom nadajemy niewielkie rozmiary początkowe i zwięk szamy je, gdy zajdzie taka potrzeba, co pozwala nam sprawdzić, czy kod zwiększający rozmiar w ogóle działa. Jeśli alokacja nie powiedzie się, przywracamy zmienne globalne do stanu po czątkowego za pomocą funkcji reset. Dzięki temu kolejne wywołanie funkcji csvgetl i ne ma szanse powodzenia : /* reset: przywraca wartości początkowe zmiennym */
stati c voi d reset (vo i d ) { free (1 i ne) ; /* Wywolaniefree(NULL)jest dozwolone w standardzie ANSI C */ free {sl i ne) ; free (fi el d) ; l i ne = NULL; s l i ne = NULL; fi el d = NULL; maxl i ne = maxfi el d nfi el d O;
Funkcja endofl i ne obsługuje różne rodzaje zakończeń wiersza wejściowego: znak powrotu karetki, znak nowego wiersza, obydwa te znaki lub nawet znak końca pliku: I* endojline: sprawdza i usuwa znaki \r, \n, \r\n oraz EOF */
stati c i nt endofl i ne ( FILE *fi n , i nt c) { i nt eol ;
4.3. BIBLIOTEKA DLA INNYCH
1 05
eol ( c== ' \r ' 1 1 c== ' \n ' ) ; i f ( c == ' \r ' ) { c = getc (fi n ) ; i f (c ! = ' \n ' && c ! = EOF) ungetc ( c, fi n) ; /* Wczytano za dużo, cofii ięcie c *I =
return eol ;
Utworzenie osobnej funkcji było konieczne, gdyż standardowe funkcje wejściowe nie ob sługują wszystkich możliwych formatów danych wejściowych. · W prototypie do znajdowania kolejnego leksemu używaliśmy funkcji strtok, która szukała przecinka jako znaku oddzielającego. Ten sposób działania uniemożliwiał nam jednak obsługę przecinków w cudzysłowach. Funkcja sp l i t wymaga dużych zmian w implementacji, aczkolwiek sam jej interfejs pozostanie nienaruszony. Rozważmy poniższe wiersze danych wejściowych:
Każdy wiersz składa się z trzech pustych pól. Aby zapewnić poprawne przetwarzanie tego rodzaju nietypowych informacji, trzeba znacznie skomplikować kod źródłowy. Jest to przykład sytuacji, w której obsługa kilku specjalnych i brzegowych przypadków może stać się dominują cą częścią kodu źródłowego programu. /* split: dzieli wiersze na pola */
stati c i nt spl i t (voi d) { char *p, **newf; char *sepp ; /* Wskaźnik na tymczasowy znak oddzielający */ i nt sepc ; /* Tymczasowy znak oddzielający *I n fi el d = O ; i f (l i ne [OJ == ' \O ' ) return O ; s trcpy ( s l i ne , l i ne) ; p = s l i ne ; do { i f (nfi el d >= maxfi eld) { max f i el d *= 2 ; /*Podwojenie aktualnego rozmiaru */ newf = (char **) real l oc ( f i el d , maxfi el d * s i zeof(fi el d [OJ ) ) ; i f (newf == NULL) return NOMEM; fi el d = newf; } i f ( *p == "" ) se pp advquoted ( ++p) ; /* Pominięcie pie1wszego cudzysłowu *I el se sepp p + strcspn ( p , fi el dsep) ; sepc = se pp [OJ ; sepp [OJ = ' \O ' ; /* Zako1iczenie pola *I fi e l d [nfi el d++J p;
1 06
4. INTERFEJSY
p = sepp + l ; whi l e (sepc == ' , ' ) ; return n fi el d ;
Oto co robi pętla: zwiększa w razie potrzeby tablicę wskaźników na pola, a następnie wy wołuje jedną funkcję lub dwie w celu zlokalizowania i przetworzenia kolejnego pola. Jeśli po brane pole zaczyna się od znaku cudzysłowu, funkcja advquoted znajduje to pole i zwraca wskaźnik na znak oddzielający oznaczający jego koniec. W przeciwnym razie szukamy następ nego przecinka przy użyciu funkcji z biblioteki standardowej o nazwie strcspn ( p , s ) , która przeszukuje łańcuch przekazany w argumencie p w celu znalezienia kolejnego wystąpienia ja kiegokolwiek znaku w argumencie s. Jej wartością zwrotną jest liczba pominiętych znaków. Ponieważ znaki cudzysłowu wewnątrz pól są reprezentowane przez dwa sąsiadujące znaki cudzysłowu, funkcja advquoted usuwa jeden z nich i dodatkowo usuwa też cudzysłowy z po czątku i końca pola. Funkcję komplikuje mechanizm obsługi pozornie poprawnych danych, które jednak nie spełniają wymogów specyfikacji, np. " abc"def. Takie problemy rozwiązujemy, za liczając do pola wszystko, co znajduje się między drugim cudzys_łowem i następnym znakiem oddzielającym. W programie Microsoft Excel chyba zastosowano podobny algorytm. /* advquoted: pole w cudzysłowie; zwraca wskaźnik na następny znak oddzielający *I
stat i c char *advquoted ( char *p) { i nt i , j ; for ( i = j = O ; p [j] ! = ' \O ' ; i ++ , j++) { i f ( p [j] == 1 11 1 && p [++j] ! = 1 11 1 ) { I* Kopiuje do napotkania następnego znaku oddzielającego lub znaku \O *I
i nt k = strcspn (p+j , fi el dsep) ; me111Tio ve (p+i , p+j , k) ; i += k ; j += k; brea k ; } p [i ] = p [j] ;
} p [i ] = ' \O ' ; return p + j ;
Gdy podzieliliśmy wiersz danych na pola, napisanie funkcji cs v fi e l d i c s vnf i e l d jest już łatwe: /* csvfield: zwraca wskaźnik na n-tepole */
char *csvfi el d ( i nt n) { i f (n < O I I n >= nfi el d) return NULL; return fi el d [n] ;
I* csvnfield: zwraca licznik pól */
i nt csvnfi el d (vo i d )
1 07
4.3. BIBLIOTEKA DLA INNYCH
return nfi el d ;
Teraz możemy zmodyfikować program testowy, aby sprawdzić nową wersję biblioteki. Po nieważ w przeciwieństwie do prototypu ta wersja przechowuje kopię wiersza wejściowego, można wydrukować oryginalny wiersz, zanim wydrukuje się pola: /* main: testuje bibliotekę CSV */
i nt mai n (vo i d) { i nt i ; char *l i ne ; whi l e ( ( l i ne = csvgetl i ne (stdi n ) ) ! = NULL) pri ntf ( " l i ne = "%s ' \n " , l i ne) ; for (i = O ; i < csvnfi el d () ; i ++) pri ntf ( " fi el d [%d] "%s ' \n " , i , csvfi el d ( i ) ) ; =
return O ;
N a tym zakończyliśmy pracę nad implementacją w języku C . Program obsługuje dowolnie dużą ilość danych wejściowych i nawet nieźle sobie radzi z nietypowymi formatami. Ceną za te udoskonalenia jest zwiększenie długości kodu ponad czterokrotnie w stosunku do prototypu i pojawienie się kilku zawiłych fragmentów kodu. Takie zwiększenie rozmiaru i poziomu zło żoności ostatecznej wersji programu jest typowym zjawiskiem.
Ćwiczenie 4.1. Operacja dzielenia wierszy na pola dobrze nadaje się do realizacji różnych ro dzajów przetwarzania leniwego. Przykładowo po odebraniu żądania jednego pola można po dzielić od razu cały wiersz, wydzielić tylko jedno wybrane pole albo wyodrębnić żądane pole i wszystkie znajdujące się przed nim. Sporządź listę możliwych metod implementacji tej funk cji, oszacuj potencjalne trudności i zalety każdej z nich, a następnie je zrealizuj i zmierz pręd kość ich działania.
Ćwiczenie 4.2. Dodaj do programu mechanizm pozwalający jako znaki oddzielające pola zasto sować (a) znaki dowolnego rodzaju; (b) różne znaki dla różnych pól; (c) wyrażenia regularne (zobacz rozdział 9.). Jak powinien wyglądać interfejs?
Ćwiczenie 4.3. Podstawowym mechanizmem wyboru sposobu działania naszego programu uczyniliśmy statyczną inicjalizację zmiennych: jeśli wskaźnik jest na początku pusty, to go ini cjalizujemy. Można też zlecić użytkownikowi obowiązek wywołania funkcji inicjalizującej, która ustawiałaby zalecane początkowe rozmiary tablic. Zaimplementuj rozwiązanie będące połączeniem zalet obu przedstawionych rozwiązań. Jaką rolę będzie odgrywać w Twojej im plementacji funkcja reset?
Ćwiczenie 4.4. Zaprojektuj i zaimplementuj bibliotekę do tworzenia danych w formacie CSV. W najprostszym wydaniu program może pobierać łańcuchy z tablicy i drukować je z dodanymi cudzysłowami i przecinkami. Bardziej zaawansowana wersja może używać łańcucha formatu podobnego do używanego przez funkcję pri nt f. Jeśli potrzebujesz podpowiedzi na temat nota cji, zajrzyj do rozdziału 9.
1 08
4. INTERFEJSY
4.4. Implementacja w języku C + + csv w języku c+ + , którego uży cie pozwoli nam rozwiązać niektóre problemy wynikające z ograniczeń języka C. W związku z tym będziemy musieli wprowadzić kilka zmian w specyfikacji programu. Najważniejsza bę dzie dotyczyła tego, że zamiast tablic znaków będziemy używać łańcuchów znaków C+ +. Ta zmiana automatycznie rozwiąże niektóre z naszych problemów związanych z zarządzaniem pamięcią, gdyż funkcje biblioteki standardowej języka c+ + zwolnią nas z obowiązku wyko nywania pewnych czynności. Procedury obsługujące pola będą zwracały łańcuchy znaków, które funkcje wywołujące są w stanie bez przeszkód przetwarzać. To znaczny postęp, jeśli cho dzi o elastyczność w stosunku do poprzedniej wersji programu. Interfejs publiczny zdefiniujemy w klasie Csv, w której zgrabnie ukryjemy zmienne i funk cje należące do implementacji. Ponieważ pojedynczy obiekt tej klasy zawiera cały stan, bę dziemy mogli tworzyć dowolną liczbę zmiennych typu Csv. Jako że każdy obiekt stanowi nie zależną jednostkę, będzie można operować na kilku strumieniach wejściowych jednocześnie. w tym podrozdziale zajmiemy się implementacją biblioteki
li Wczytuje wartości oddzielane przecinkami i analizuje ich składnię li Przykładowe dane wejściowe: "LU",86.25, "111411998", "2:19PM", +4. 0625
cl ass Csv {
publ i c : Csv ( i stream& fi n = ci n , stri ng sep fi n ( fi n) , fi el dsep ( sep) { }
" , ")
i nt getl i ne(stri ng&) ; stri ng getfi el d ( i nt n) ; i nt getnfi el d () con st { return n fi el d ; } pri vate : i stream& fi n ; stri ng l i ne ; vector fi el d ; i nt nfi el d ; stri ng fi el dsep ; i nt i nt i nt i nt
li Wskaźnik na plik wejściowy li Wiersz wejściowy li Łańcuchy reprezentujące pola li Liczba pól li Znaki oddzielające
spl i t ( ) ; endofl i ne (char) ; advp l a i n (const stri ng& l i ne , stri ng& fl d , i nt) ; advquoted (const stri ng& l i ne , stri ng& fl d , i nt) ;
}; Ponieważ zdefiniowaliśmy domyślne parametry konstruktora, domyślny obiekt klasy Csv będzie wczytywał dane z e standardowego strumienia wejściowego przy użyciu normalnego znaku oddzielającego pola. Oba argumenty można zastąpić jawnymi wartościami. Do zarządzania łańcuchami znaków w klasie wykorzystywane są standardowy typ stri ng języka C+ + i klasa vector (zrezygnowaliśmy z łańcuchów w stylu języka C). W typie stri ng nie istnieje coś takiego jak brak stanu: pusty łańcuch to tylko łańcuch o zerowej długości; nie ma też odpowiednika wartości N U L L, a więc nie można jej użyć do oznaczania końca pliku. W związku z tym argument funkcji Csv : : get l i ne wykorzystaliśmy do przesłania referencji do wiersza danych wejściowych w postaci łańcucha, a sama wartość zwrotna posłuży nam do przekazywania informacji o końcu pliku i błędach.
4.4. IMPLEMENTACJA W JĘZYKU C+ +
1 09
li getline: pobierajeden wiersz, zwiększa rozmiar w razie potrzeby
i nt Csv : : getl i n e (stri ng& str) {
char c ; for ( l i ne = " " ; fi n . get(c) && ! endofl i ne (c) ; ) l i ne += c ; spl i t ( ) ; str = l i ne ; return ! fi n . eo f ( ) ;
Operator += występuje tu w wersji przeciążonej dołączającej znak na końcu łańcucha. Kod funkcji endofl i ne wymaga tylko drobnych poprawek. Tu również musimy wczytywać dane wejściowe po jednym znaku, ponieważ żadna standardowa funkcja nie potrafi obsłużyć całej różnorodności wszystkich możliwych formatów. li endojline: sprawdza i usuwa znaki \r, \n, \r\n oraz EOF
i nt Csv : : endofl i ne (char c) { i nt eol ; eol = (c== ' \r ' I I c== ' \n ' ) ; i f ( c == ' \r ' ) { fi n . get(c) ; i f ( ! fi n . eof() && c ! = ' \n ' ) fi n . putback ( c) ; li Wczytano za dużo return eol ;
Oto nowa wersja funkcji spl i t : li split: dzieli wiersze na pola
i nt Csv : : spl i t ( ) {
stri ng fl d ; i nt i , j ; nfi el d = O ; i f ( l i ne . l ength ( ) == O) return O ; i = O; do i f (i < l i ne . l ength ( ) && l i ne [i] == "" ) j advquoted ( l i ne , fl d , ++i ) ; llPomija cudzysłów el se j = advpl ai n (l i ne , fl d , i ) ; i f (nfi el d >= fi el d . si z e ( ) ) fi el d . push_bac k ( fl d) ; el se fi el d [nfi el d] = fl d ; nfi el d++; =
1 10
4. INTERFEJSY
i = j + l; whi l e (j < l i ne . l ength ( ) ) ; return nfi el d ;
Ponieważ funkcja strcspn nie obsługuje łańcuchów języka C + +, jesteśmy zmuszeni zmo dyfikować zarówno funkcję spl i t, jak i advquoted. W nowej wersji tej drugiej z wymienionych funkcji użyjemy standardowej funkcji języka c+ + o nazwie fi nd _fi rst_o f, która posłuży nam do znajdowania kolejnego wystąpienia znaku oddzielającego. Wywołanie s . fi nd_fi rst_ '+of ( fi el dsep , j ) przeszukuje łańcuch s w celu znalezienia pierwszego wystąpienia którego kolwiek ze znaków znajdujących się w argumencie fi e l dsep na pozycji j lub za nią. Jeśli nic nie znajdzie, to zwraca indeks o numerze spoza łańcucha, a więc musimy się cofnąć. Znajdują ca się dalej pętla wewnętrzna for dołącza do łańcucha f1 d wszystkie znaki, które mieściły się przed szukanym znakiem oddzielającym.
li advquoted: pole w cudzysłowie; zwraca indeks kolejnego znaku oddzielającego i nt Csv : : advquoted (const stri ng& s , stri ng& fl d , i nt i ) { i nt j ; fl d = " " ; for (j = i ; j < s . l ength ( ) ; j ++) { i f ( S [j ] ) && S [++ j] ! = i nt k = s . fi nd fi rst of(fi el dsep, j ) ; i f ( k > s . l ength () ) li Nie znaleziono żadnego znaku oddzielającego k = s . l ength ( ) ; for (k -= j ; k-- > O ; fl d += s [j ++] ; break; ::
I U I
° '1 1
fl d += s [j] ; return j ;
Funkcji fi nd_fi rst_of używamy także w nowej funkcji o nazwie advp l a i n służącej do przeglądania zwykłych pól nieujętych w cudzysłowy. Ta zamiana również została wymuszona przez to, że funkcje działające na łańcuchach w stylu języka C (np. strcspn) nie działają na łańcuchach języka c+ +, ponieważ są to całkiem różne typy danych.
li advplain: pole nieujęte w cudzysłów; zwraca indeks następnego znaku oddzielającego i nt Csv : : advpl ai n (const stri ng& s , stri ng& fl d , i nt i ) { i nt j ; j = s . fi nd fi rst of (fi el dsep, i ) ; li Szuka znaku oddzielającego if (j > s . length() ) li Nie znaleziono j = s . l ength ( ) ; fl d = stri ng ( s , i , j - i ) ; return j ;
111
4.4. IMPLEMENTACJA W JĘZYKU C+ +
Tak jak poprzednio napisanie funkcji Csv : : getfi el d to banalnie proste zadanie, a imple mentacja funkcji Csv : : getnfi el d jest tak krótka, że umieściliśmy ją w definicji klasy. li getjield: zwraca n-te pole
string Csv : : getfi el d ( i nt n) { i f (n < O 1 1 n > = nfi el d) return " " ; e l se return fi el d [n] ;
Program testowy również przypomina poprzednią wersję: li main: testuje klasę Csv
i nt mai n (voi d) { stri ng l i ne ; Csv csv;
whi l e (csv . getl i ne ( l i ne) ! = O) { cout << " l i ne << l i ne <<11 1 \n " ; for ( i nt i = O ; i < csv . getnfi el d () ; i ++) cout << " fi e l d [ " << i << ] = · n « csv . getfi el d ( i ) « " ' \n " ; =
· n
"
return O ;
Sposób użycia tego programu niewiele różni się o d wersji w języku C. Przy dużym pliku wejściowym zawierającym 30 tysięcy wierszy po około 25 pól każdy wersja napisana w języku C+ + działa o około 40% do czterech razy wolniej od wersji w języku C, w zależności od użyte go kompilatora. Jak zauważyliśmy w czasie porównywania różnych wersji programu markov, różnice te wynikają z niedoskonałości biblioteki. Program w języku C + + jest o około 20% krótszy.
Ćwiczenie 4.5. Rozszerz implementację w języku C+ + o przeciążenie operatora [] , aby dostęp do pól można było uzyskiwać za pomocą notacji csv [i ] . Ćwiczenie 4.6. Zaimplementuj bibliotekę CSV w Javie, a następnie porównaj wszystkie trzy wersje pod względem klarowności kodu, niezawodności i szybkości działania. Ćwiczenie 4.7. Napisz nową wersję biblioteki CSV w języku C+ + przy użyciu iteratorów z biblio teki STL.
Ćwiczenie 4.8. w wersji programu napisanej w języku c + + możliwe jest działanie wielu nie zależnych egzemplarzy klasy Csv jednocześnie. Jest tak dzięki zamknięciu całego opisu stanu w jednym obiekcie, który może występować w wielu egzemplarzach. Zmodyfikuj wersję w ję zyku C tak, aby uzyskać w nim ten sam efekt. W tym celu zamień globalne struktury danych na struktury alokowane i inicjalizowane za pomocą jawnego wywołania funkcji csvnew.
1 12
4. INTERFEJSY
4.5 . Zasady projektowania interfejsów We wcześniejszych podrozdziałach pracowaliśmy nad interfejsem, który stanowi granicę mię dzy kodem świadczącym usługi a kodem, który z tych usług korzysta. Interfejs określa, co pewna część programu robi dla użytkowników, jak składające się nań funkcje i czasami dane mogą zostać uŻyte w pozostałej części programu. Interfejs CSV, który zaprojektowaliśmy, za wiera trzy funkcje - wczytującą wiersz danych, wyodrębniającą pola i zwracającą licznik pól. Są to jedyne możliwe do wykonania operacje. Interfejs powinien być właściwie dostosowany do zadania, które ma wykonywać - powi nien być prosty, ogólny, regularny, przewidywalny i niezawodny - oraz musi elegancko przy stosowywać się do zmian w wymaganiach użytkowników i implementacji. Dobre interfejsy tworzy się według pewnych zasad. Nie są one wzajemnie zależne ani nawet spójne, ale pomagają opisać, co się dzieje na styku dwóch części oprogramowania.
Ukrywanie szczegółów implementacji. Wewnętrzne mechanizmy implementacji interfejsu powinny być ukryte przed resztą programu, aby można było je w razie potrzeby zmienić bez konieczności modyfikowania czegokolwiek innego. Zasada ta ma wiele nazw, np. ukrywanie informacji, hermetyzacja, abstrakcja, modularyzacja itp. Każda z nich oznacza mniej więcej to sarno. Wszystkie szczegóły implementacji interfejsu, które nie są potrzebne jego użytkowni kom, powinny być ukryte. Niewidoczne szczegóły implementacji interfejsu można zmieniać w sposób niezauważalny dla klientów (użytkowników), co pozwala np. na jego bezproblemowe rozszerzanie, optymalizowanie czy wręcz na całkowitą wymianę wszystkich wewnętrznych me chanizmów. Przykłady stosowania zasady ukrywania informacji można znaleźć w podstawowych biblio tekach większości języków programowania. Nie zawsze są one jednak idealnie zrealizowane. Do najszerzej znanych należy biblioteka wejścia i wyjścia języka C, która zawiera kilkadziesiąt funkcji służących do otwierania, zamykania, odczytywania, zapisywania i przetwarzania plików na jeszcze wiele innych sposobów. Implementacja mechanizmów wejścia i wyjścia plików jest ukryta w typie danych FI LE*. Wiele jej szczegółów można znaleźć w nagłówku , ale nie należy tej wiedzy wykorzystywać. Jeśli w pliku nagłówkowym nie ma rzeczywistej deklaracji struktury, lecz jest wyłącznie jej nazwa, to strukturę taką nazywa się typem nieprzezroczystym, ponieważ jej właściwości są niewidoczne, a wszelkie związane z nią operacje wykonuje się za pośrednictwem wskaźnika na realny obiekt tej struktury. Staraj się jak najmniej używać zmiennych globalnych. Zawsze lepiej jest, jeśli to możliwe, wykorzystywać argumenty funkcji do przekazywania referencji do danych. Jesteśmy zdecydowanie przeciwni publicznemu udostępnianiu jakichkolwiek informacji. Zachowanie spójności danych jest o wiele trudniejsze, jeśli użytkownicy mogą zmieniać warto ści zmiennych wedle własnego upodobania. Egzekwowanie przestrzegania zasad dostępu uła twia stosowanie interfejsów funkcji, ale ta zasada bywa często łamana. Standardowe strumienie wejścia i wyjścia, takie jak stdi n i stdout, są prawie zawsze definiowane jako elementy globalnej tablicy struktur typu FI LE:
extern FILE i ob [-NFI LE] ; #defi ne stdi n- (& i ob [O] ) #defi ne stdout (g;:- i ob [l] ) #defi ne stderr ( &:=:i ob [2] )
1 13
4.5. ZASADY PROJEKTOWANIA INTERFEJSÓW
Przez to implementacja jest całkowicie widoczna. Co więcej, mimo iż stdi n, stdout i stderr wyglądają jak zmienne, nie można ich użyć w instrukcji przypisania. Dziwnie wyglądająca na zwa _i ob (na początku są dwa znaki podkreślenia) to konwencjonalny w standardzie ASCII C sposób zapisu prywatnych nazw, które muszą być widoczne. Dzięki temu zmniejsza się ryzyko wystąpienia konfliktu z innymi nazwami używanymi w programie. W językach C+ + i Java istnieje lepszy sposób ukrywania informacji - ukrywanie pod po stacią klas. W istocie klasy stanowią podstawę poprawnego korzystania z tych języków. O krok dalej posunięto się przy projektowaniu klas kontenerowych w bibliotece STL języka C+ + (którą opisaliśmy w rozdziale 3.). Oprócz pewnych gwarancji dotyczących wydajności brak ja kichkolwiek informacji na temat implementacji, a więc twórcy bibliotek mogą używać dowol nych mechanizmów.
Wybierz niewielki ortogonalny zbiór podstawowych operacji. Dobrze jest, gdy interfejs udo stępnia dokładnie tyle funkcji, ile potrzeba, przy czym zakresy ich działania nie powinny się w zbyt dużym stopniu pokrywać. Im więcej funkcji w bibliotece, tym łatwiej się z niej korzysta, ponieważ wszystko, czego trzeba, jest pod ręką. Ale duży interfejs trudno jest napisać i trudno nim zarządzać, a poza tym sam rozmiar może utrudniać jego poznawanie. Niektóre interfejsy programistyczne (ang. application programming interface tzw. API) są wręcz tak rozbudowane, że żaden śmiertelnik nie jest w stanie opanować ich w całości. Pewne interfejsy dla samej tylko wygody umożliwiają wykonywanie niektórych czynności na wiele sposobów. Należy wystrzegać się takiego podejścia do ich projektowania. W standar dowej bibliotece wejścia i wyjścia języka C można znaleźć co najmniej cztery różne funkcje służące do wysyłania na wyjście pojedynczych znaków: -
char c ; putc ( c , fp) ; fputc ( c , fp) ; fpri ntf{fp , "%c " , c) ; fwri t e (&c , s i zeof (char) , 1 , fp) ; Jeśli dane mają pójść do strumienia stdout, możliwości ich wysłania jest jeszcze więcej. To wygodne dla programisty, ale niepotrzebne. Z zasady preferowane są zwięzłe interfejsy, których nie należy rozszerzać, jeśli nie ma się do tego bardzo dobrych powodów. Skup się na jednym i zrób to dobrze. Nie dodawaj nic do interfejsu tylko dlatego, że się da, i nie poprawiaj go, jeśli problemy sprawia nie on, lecz im plementacja. Na przykład zamiast szybkiej funkcji memcpy i bezpiecznej funkcji memmove lepiej utworzyć jedną funkcję, która jest bezpieczna i w miarę możliwości jak najszybsza.
Nie działaj w tajemnicy przed użytkownikiem. Funkcje biblioteczne nie powinny w tajemni cy tworzyć plików i zmiennych ani modyfikować danych globalnych. Także ze zmienianiem danych u wywołującego należy być powściągliwym. Niektóre z tych zasad łamie funkcja str tok. Jest czymś zaskakującym, że wstawia ona puste bajty do środka łańcucha, który otrzymuje na wejściu. Pusty wskaźnik używany przez nią do oznaczania miejsca, w którym zakończyła się poprzednia operacja, to sekretna informacja wstawiana do danych między kolejnymi wywoła niami funkcji. Jest to potencjalne źródło błędów, dodatkowo uniemożliwiające równoległe wy konywanie funkcji (zobacz ćwiczenie 4.8). Możliwość użycia jednego interfejsu nie powinna być uzależniona od dostępności innego tylko dlatego, że tak było wygodniej programiście, który go tworzył. Spraw, aby tworzony przez Ciebie interfejs był samowystarczalny, a jeśli to niemożliwe, wyraźnie napisz, jakie
1 14
4. INTERFEJSY
zewnętrzne pomoce są potrzebne. Jeżeli tego nie zrobisz, utrudnisz klientowi utrzymanie oprogra mowania. Doskonałym przykładem pogwałcenia tych zasad jest zmuszanie programistów języków c i c+ + do wpisywania koszmarnie długich list plików nagłówkowych w plikach źródłowych programów. Nagłówki mogą zawierać po kilka tysięcy wierszy kodu i dołączać dziesiątki innych nagłówków.
Tę samą czynność wykonuj zawsze tak samo. Bardzo ważna jest spójność i regularność dzia łań. Do osiągnięcia zbliżonych celów należy zawsze używać podobnych środków. Z funkcji łańcuchowych z biblioteki języka C korzysta się bardzo łatwo nawet bez dokumentacji, ponie waż wszystkie działają podobnie: kierunek przepływu danych jest taki sam jak w instrukcjach przypisania, czyli od lewej do prawej strony, oraz wszystkie te funkcje zwracają łańcuchy, które wytworzyły w czasie działania. Trudno natomiast w standardowej bibliotece wejścia i wyjścia języka C przewidzieć kolejność argumentów w funkcjach. W niektórych argument FI LE* wy stępuje jako pierwszy, a w innych jako ostatni. W pewnych funkcjach rozmiar i liczba elemen tów są pomieszane. Interfejs algorytmów w bibliotece STL jest bardzo spójny, dzięki czemu łatwo domyślić się, jaki jest sposób użycia nawet nieznanych funkcji. Nie mniej wartym zachodu celem jest zadbanie o tzw. spójność zewnętrzną, czyli o to, aby różne niepowiązane ze sobą funkcje działały podobnie. Na przykład w języku C funkcje do za rządzania pamięcią zaprojektowano później niż funkcje operujące na łańcuchach, ale pożyczo no od nich styl. Ze standardowych funkcji wejścia-wyjścia fread i fwri te korzystałoby się znacznie łatwiej, gdyby były one podobne do swoich pierwowzorów read i wri te. W wierszu poleceń systemu Unix przed opcjami stawia się znak minus, ale ta sama litera w różnych, nawet powiązanych ze sobą programach może oznaczać coś całkiem innego. Jeśli symbole wieloznaczne, takie jak np. * w wyrażeniu * . exe, są rozwijane przez interpre ter poleceń, to jest to robione zawsze tak samo. Jeżeli jednak robią to poszczególne programy, wówczas każdy z nich może to wykonywać w inny sposób. Aby skorzystać z odnośnika w prze glądarce internetowej, należy go kliknąć jeden raz. Natomiast aby uruchomić program lub przejść w jakieś miejsce, trzeba kliknąć dwa razy. Z tego powodu wielu użytkowników kompu tera klika dwukrotnie i tu, i tu. W jednych środowiskach powyższe zasady są łatwiejsze do przestrzegania niż w innych, ale one obowiązują wszędzie. Przykładowo w języku C trudno jest ukryć szczegóły implementa cyjne, ale dobry programista nie będzie tego wykorzystywał, gdyż stanowiłoby to pogwałcenie zasady ukrywania informacji. Kiedy nie można zmusić użyrkowników do właściwego zacho wania, często stosuje się różne środki zachęcające, takie jak komentarze w plikach nagłówkowych czy specjalne nazwy typu i ob. Mimo wszelkich starań nasze możliwości w zakresie doskonalenia interfejsu są ograniczone. Nawet najlepsze dzisiejsze interfejsy mogą w przyszłości zacząć sprawiać problemy, ale jeśli je dobrze zaprojektujemy, to możemy ten moment w przyszłości nieco oddalić.
4.6. Zarządzanie zasobami Jednym z największych problemów, jakie trzeba rozwiązać przy projektowaniu interfejsu dla biblioteki (albo klasy lub pakietu), jest zarządzanie zasobami będącymi własnością tej bibliote ki lub należącymi do biblioteki i programów z niej korzystających. Takim zasobem, który od razu przychodzi na myśl, jest pamięć - kto powinien ją przydzielać i zwalniać? Przykłady in nych wspólnych zasobów to otwarte pliki i stany zmiennych, których wartości są wspólnie wy korzystywane. Rozważane problemy można podzielić na takie kategorie, jak: inicjalizacja, utrzymywanie stanu, współdzielenie i kopiowanie oraz usuwanie.
4.6. ZARZĄDZANIE ZASOBAMI
1 15
W prototypie naszej biblioteki CSV do ustawiania wartości początkowych (wskaźników, liczników itp.) zastosowaliśmy inicjalizację statyczną. Decyzja ta spowodowała jednak pewne ograniczenia, gdyż uniemożliwia przywrócenie procedur do pierwotnego stanu, jeśli któraś z funkcji została wywołana. Alternatywnym rozwiązaniem może być utworzenie funkcji ini cjalizującej wszystkie wartości wewnętrzne odpowiednimi wartościami początkowymi. To po zwala ponownie uruchamiać procedury, ale trzeba liczyć na to, że użytkownik sam taką funkcję wywoła. W drugiej wersji programu można by wykorzystać do tego celu funkcję reset, zmie niając jej definicję na publiczną. w językach c+ + i Java do inicjalizacji składowych elementów danych klas używa się kon struktorów. Dobrze zaprojektowany konstruktor zapewnia inicjalizację wszystkich niezbędnych zmiennych składowych oraz uniemożliwia utworzenie niezainicjalizowanego obiektu. Jest możliwe tworzenie różnych konstruktorów spełniających nieco inne zadania, np. w klasie Csv można by utworzyć dwa konstruktory: pobierający na wejściu nazwę pliku i strumień wejściowy. Co z kopiami informacji zarządzanych przez bibliotekę, takimi jak wiersze i pola wejścio we? W programie csvgetl i ne, który napisaliśmy w języku C, umożliwiliśmy bezpośredni do stęp do łańcuchów wejściowych (wierszy i pól), zwracając wskaźniki na nie. Taki nieograni czony dostęp ma kilka wad. Użytkownik może zmienić zawartość pamięci i uszkodzić w ten sposób inne dane. Przykładowo wykonanie wyrażenia:
strcpy (csvfi el d ( l } , csvfi e l d ( 2) ) ; może się nie powieść z wielu powodów, np. jeśli pole nr 2 będzie dłuższe od pola nr l , to początek pola nr 2 może zostać skasowany. Użyrkownik biblioteki chcąc kolejny raz wywołać funkcję csvgetl i ne, musi wpierw przygotować kopię wszystkich informacji, które chce za chować. Po wykonaniu poniższej sekwencji instrukcji możemy otrzymać niepoprawny wskaź nik, jeżeli drugie wywołanie funkcji csvget l i ne spowoduje realokację bufora wiersza:
char * p ; csvgetl i ne ( fi n} ; p = csvf i el d ( l } ; csvget l i n e ( fi n } ; /* W tym miejscu wskaźnik p może być niepoprawny */
Wersja napisana w języku C + + jest bezpieczniejsza, gdyż pracuje na kopiach łańcuchów, które można modyfikować do woli. W Javie do obiektów, czyli wszystkiego, co nie jest typem podstawowym, takim jak i nt, można odwoływać się za pomocą referencji. To podejście jest bardziej wydajne niż tworzenie kopii, ale można ulec złudzeniu, że referencja jest kopią obiektu. Taki błąd przydarzył się nam w czasie pisania pierwszej wersji programu markov w Javie. Jest to nieustające źródło błędów związanych z obsługą łańcuchów w stylu języka C. W razie potrzeby można wykonać kopię łańcucha za pomocą jednej z metod klonujących. Uzupełnieniem inicjalizacji, czyli konstrukcji obiektów, jest ich finalizowanie, a więc usu wanie. Polega to na wykonaniu operacji porządkujących i odzyskaniu zasobów, które były używane przez niepotrzebną już jednostkę. Szczególne znaczenie ma to w przypadku pamięci. Jeśli program nie będzie jej odzyskiwał po usuniętych obiektach, to w końcu mu jej zabraknie. Z zażenowaniem obserwujemy, w jak wielu nowoczesnych programach występują tego typu niedociągnięcia. Podobne problemy dotyczą zamykania otwartych plików: jeżeli zapisujemy dane w buforze, to bufor ten kiedyś trzeba będzie opróżnić (a zajmowaną przez niego pamięć odzyskać). Standardowe funkcje języka C automatycznie opróżniają bufory, jeśli program
1 16
4. INTERFEJSY
zostanie zamknięty normalnie. W pozostałych przypadkach trzeba to zaprogramować samo dzielnie. Funkcja a tex i t języków C i C+ + pozwala przejąć sterowanie wykonywaniem pro gramu tuż przed jego zamknięciem. Programiści interfejsów mogą ją zastosować do wykonania w odpowiednim momencie procedur porządkowych.
Zwalniaj zasoby w tej samej warstwie, w której zostały alokowane. Jednym ze sposobów sprawowania kontroli nad alokacją i odzyskiwaniem zasobów jest zlecenie wykonywania obu tych czynności tej samej bibliotece, pakietowi lub interfejsowi. Innymi słowy, stan alokacji zasobu nie powinien się zmieniać w obrębie interfejsu. Nasze biblioteki CSV pobierają dane z otwar tych plików, a więc po zakończeniu pracy pliki te również pozostawiają otwarte. Ich zamknięcie to zadanie dla programu wywołującego bibliotekę. W języku C + + przestrzeganie tych zasad ułatwiają konstruktory i destruktory. Gdy eg zemplarz klasy staje się bezużyteczny albo zostaje jawnie usunięty, następuje wywołanie jego destruktora. Destruktor może opróżnić bufory, odzyskać pamięć, przywrócić początkowy stan zmiennych i wykonać wszystkie inne tego rodzaju czynności. W Javie nie ma takiego mechani zmu. Wprawdzie w klasie można zdefiniować metodę finalizującą, ale nie ma gwarancji, że zo stanie ona wykonana, nie mówiąc już o możliwości precyzyjnego wyboru momentu jej wyko nania. Nie można zatem mieć pewności, iż procedury porządkowe zostaną wywołane, aczkolwiek w wielu przypadkach można zakładać, że tak się stanie: Język Java bardzo ułatwia zarządzanie pamięcią poprzez swój algorytm usuwania nieużyt ków (ang. garbage collection). Działający program alokuje w pamięci obiekty. Mimo iż nie da się ich usunąć jawnie, system wykonawczy sprawdza, które z nich są jeszcze w użyciu, a które nie, i co jakiś czas przywraca nieużywaną pamięć do puli dostępnej pamięci. Algorytm usuwania nieużytków bywa realizowany na wiele sposobów. W niektórych im plementacjach rejestruje się liczbę użyć każdego obiektu, jest to tzw. licznik odniesień (ang. reference count), i jeśli liczba ta dla danego obiektu spadnie do zera, obiekt ten zostaje usunięty. Techniki tej można jawnie używać w językach C i C+ + do zarządzania wspólnymi obiektami. Inne algorytmy co pewien czas śledzą przydziały z ogólnej puli pamięci do wszystkich obiek tów, do których występują odniesienia. Obiekty znalezione w ten sposób są cały czas używane. Natomiast te, do których nie odwołuje się żaden inny obiekt, są nieużywane i można je usunąć. Samo istnienie automatycznego systemu usuwania nieużytków nie oznacza jednak całko witego wyeliminowania problemów z zarządzaniem pamięcią. Nadal konieczne jest sprawdza nie, czy interfejsy zwracają referencje do wspólnych obiektów, czy do ich kopii, i dotyczy to całego programu. Oprócz tego system usuwania nieużytków nie jest darmowy. Nie dość, że utrzymywanie informacji i przywracanie nieużywanej pamięci generuje dodatkowe koszty, to jeszcze nie można przewidzieć, kiedy system zostanie uruchomiony. Wszystkie te problemy są jeszcze spotęgowane w systemach wielowątkowych, takich jak wielowątkowe programy w Javie. Rozwiązaniem w tym przypadku jest pisanie programów wielowejściowych (ang. reentrant), a więc takich, które działają bez względu na liczbę równocześnie wykonywanych wątków wy konawczych. W kodzie wielowejściowym należy unikać zmiennych globalnych, statycznych zmiennych lokalnych i wszystkich innych rodzajów zmiennych, które mogą zostać zmodyfi kowane przez jeden wątek, podczas gdy są używane przez inny. Kluczem do sukcesu przy pro jektowaniu programu wielowątkowego jest precyzyjne odgraniczenie jego poszczególnych komponentów, tak aby ich część wspólna była realizowana wyłącznie przez dobrze zdefiniowa ny interfejs. Biblioteki, które wbrew zamierzeniom udostępniają zmienne do użytku, rujnują ten model (w programie wielowątkowym funkcje typu strtok byłyby katastrofą, podobnie jak wszystkie pozostałe funkcje z biblioteki języka C, przechowujące wartości w wewnętrznej sta tycznej pamięci). Aby zmienna mogła być używana wspólnie, musi być chroniona specjalną blokadą umożliwiającą dostęp do niej tylko jednemu wątkowi naraz. Bardzo pomocne są tu
1 17
4.7. OBSŁUGA BŁĘDÓW
klasy, gdyż stanowią one podstawę rozważań na temat modeli współużytkowania i blokowania danych. Synchroniczne metody w Javie pozwalają zablokować przez wątek całą klasę lub eg zemplarz klasy w celu ochronienia ich przed modyfikacją przez inny wątek. Synchronizacja bloków to technika pozwalająca ograniczyć liczbę wątków wykonujących określoną sekcję kodu do jednego. Programowanie wielowątkowe to bardzo skomplikowane zagadnienie, które jest zbyt ob szerne, aby je szczegółowo omówić w tej książce.
4.7. Obsługa błędów W poprzednich rozdziałach do obsługi błędów używaliśmy takich funkcji, jak epri ntf i estrdup, które przed zamknięciem programu wyświetlały stosowne komunikaty. Na przykład funkcja epri n t f zachowuje się jak wywołanie fpri ntf ( stderr, . . . ) , ale przed zamknięciem programu informuje użytkownika o zaistniałej sytuacji. Funkcja ta wykorzystuje nagłówek i funkcję biblioteczną vfpri ntf d o drukowania argumentów reprezentowanych w prototypie przez wielokropek. Przed rozpoczęciem korzystania z biblioteki stdarg trzeba ją zainicjalizo wać za pomocą wywołania funkcji v a_start, a po zakończeniu jej używania należy wywołać funkcję va_ end. Interfejsu tego będziemy jeszcze używać w rozdziale 9.
#i ncl ude #i ncl ude #i ncl ude I* eprintf: drukuje komunikat o błędzie i zamyka program *I
voi d epri ntf (char *fmt , . . . ) { va_l i st args ; ffl ush (stdout) ; i f (progname { ) ! = NU LL) fpri ntf(stderr. "%s : "
progname ( ) ) ;
va start (args , fmt) ; vfpri ntf(stderr, fmt , args) ; va_en d ( args) ; i f ( fmt [O] ! ' \O ' && fmt [strl en (fmt ) - 1] ' : ') fpri ntf(stderr, "%s " , strerror(errno) ) ; fpri ntf(stderr, " \ n " ) ; exi t (2 ) ; I* Standardowa wartość oznaczająca błąd wykonywania *I =
==
Jeśli zakończenie argumentu jest oznaczone dwukropkiem, funkcja endpri ntf wywołuje standardową funkcję języka C o nazwie strerror, zwracającą łańcuch zawierający wszystkie dostępne informacje systemowe o błędzie. Dodatkowo napisaliśmy jeszcze funkcję wepri ntf, która działa podobnie do epri nt f, ale nie zamyka programu po wyświetleniu komunikatu o błędzie. Interfejs udostępniający funkcje zbliżone do pri n t f ułatwia tworzenie łańcuchów możliwych do wydrukowania albo wyświetlenia w oknie dialogowym.
1 18
4. INTERFEJSY
Analogicznie funkcja es trdup próbuje utworzyć kopię łańcucha i jeśli nie zdoła tego zrobić z powodu braku pamięci, zamyka program i zgłasza (za pomocą funkcji epri n t f) komunikat o błędzie: /* estrdup: kopiuje la1icuch i informuje o ewentualnych błędach */
char *estrdup (char *s) { char * t ; t = (char *) mal l oc ( strl en ( s ) +l) ; i f (t == NULL) epri ntf( "Wykonan i e funkcj i ( \ " % . 20s\ " ) n i e powi od1o s i ę : " , s) ; strcpy ( t , s) ; · return t ;
Funkcja ema 1 1 oc zachowuje się podobnie do wywołań funkcji m a 1 1 oc: /* emalloc: alokuje pamięć i zgłasza ewentualny błąd */
voi d *emal l oc ( s i ze-t n) {
void *p; p = mal l oc (n) ; i f (p == NULL) epri ntf( "Al o kacj a %u bajtów funkcj ą mal l oc n i e powi od1a s i ę : " , n) ; return p ;
W pliku nagłówkowym eprintf.h znajdują się deklaracje następujących funkcji: /* eprintfh: funkcje opakowujące obsługi błędów */
extern extern extern extern extern extern extern
void voi d char void void char voi d
epri ntf (char * , . . . ) ; wepr i n t f ( char * , . . . ) ; *estrdup (char *) ; *emal l oc (s i ze t) ; *ereal l oc (voi d *, s i z e_t) ; *progname (voi d) ; setprogname (char *) ;
Ten nagłówek należy dołączyć do wszystkich plików, w których używane są funkcje obsłu gi błędów. Każdy komunikat o błędzie zawiera także nazwę programu, jeśli została ona usta wiona przez wywołującego. Do ustawiania nazwy służą proste w użyciu funkcje setprogname i progname, których deklaracje znajdują się w pliku nagłówkowym, a definicje w tym samym pliku źródłowym, co definicja funkcji epri ntf:
stat i c char *name
=
NULL ; /* Nazwa programu do użycia w komunikatach o błędach */
/* setprogname: ustawia zapisywaną nazwę programu */
voi d setprogname (char *str) { name = estrdup (str) ;
4.7. OBSŁUGA BŁĘDÓW
1 19
I* progname: zwraca zapisaną nazwę programu *I
char *progname (vo i d ) { return name;
Typowy sposób użycia tych funkcji:
i nt mai n ( i nt arg c , char *argv [] ) { setprogname ( "markov " ) ; f fopen ( argv [ i ] , " r " ) ; i f (f NULL) epri ntf ( " N i e można otworzye pl i ku %s : " , argv [i ] ) ; =
==
Przykładowy wynik:
markov : n i e można otworzyE pl i ku psa l m . txt : brak pl i ku l ub katal ogu Funkcje te bardzo ułatwiają nam programowanie. Nie dość że pozwalają ujednolicić obsługę błędów, to jeszcze sama dostępność tych funkcji sprawia, iż chętniej przechwytujemy błędy, zamiast je ignorować. Sam nasz projekt nie jest jednak w żadnej mierze wyjątkowy i w innych programach można zastosować odmienne podejście. Wyobraźmy sobie, że funkcje piszemy nie na własny użytek, lecz tworzymy bibliotekę do użytku przez innych programistów. Jak funkcja z takiej biblioteki powinna się zachowywać w przypadku wystąpienia błędu, którego nie da się naprawić? Napisane wcześniej przez nas funkcje po prostu wyświetlają stosowną informację i zamykają program. Takie zachowanie jest dopuszczalne w wielu programach, zwłaszcza niewielkich samodzielnych narzędziach i aplika cjach. W wielu przypadkach jednak zamykanie programu jest złym podejściem, gdyż unie możliwia innym częściom programu podjęcie prób wyjścia z trudnej sytuacji. Na przykład edy tor tekstu musi radzić sobie z błędami, aby móc zapisać aktualnie przetwarzany dokument. W niektórych sytuacjach procedury biblioteczne nie powinny nawet wyświetlać żadnych ko munikatów, ponieważ program może działać w środowisku, w którym taka wiadomość mogła by ingerować w wyświetlane dane albo zniknąć bez śladu. Dobrym rozwiązaniem awaryjnym w podobnych sytuacjach jest zapisywanie danych diagnostycznych w osobnym dzienniku, by można je było tam spokojnie przeanalizować.
Wykrywaj błędy na niskim, a obsługuj je na wysokim poziomie. Jest to podstawowa zasada obsługi błędów: należy je wykrywać na jak najniższym poziomie, a obsługiwać - na poziomie wysokim. W większości przypadków o sposobie obsługi błędów powinien decydować program wywołujący, a nie wywoływany. Pomocne w tym mogą być funkcje biblioteczne, jeśli będą ele gancko zachowywać się w momencie wystąpienia błędu. To rozumowanie doprowadziło nas do decyzji, aby zwracać wartość NULL, gdy nie ma pola, zamiast zamykać program. Podobnie funk cja csvgetl i ne zwraca wartość NULL bez względu na to, ile razy zostanie wywołana po napo tkaniu pierwszego końca pliku. Nie zawsze jest oczywiste, jaka wartość powinna być zwracana, o czym przekonaliśmy się wcześniej, rozważając, co powinna zwracać funkcja csvgetl i ne. Należy zwracać tak dużo poży tecznych informacji, jak to możliwe, ale w formie, w której łatwo je wykorzystać w pozostałych
1 20
4. INTERFEJSY
częściach programu. W językach C, C+ + i Java oznacza to zwracanie czegoś jako wartości funkcji oraz innych wartości poprzez argumenty referencyjne (wskaźnikowe). Działanie wielu funkcji bibliotecznych jest uzależnione od możliwości odróżniania normalnych wartości od błędów. Funkcje wejściowe, takie jak getchar, zwracają znak po otrzymaniu poprawnych da nych albo - w pozostałych przypadkach - jakąś wartość innego typu niż char, np. EOF dla końca pliku. Mechanizm ten nie działa, jeśli funkcja w wyniku poprawnego działania może zwracać wszystkie możliwe wartości. Przykładowo funkcja matematyczna l og może zwrócić każdą licz bę zmiennoprzecinkową. W standardzie IEEE opisującym liczby zmiennoprzecinkowe istnieje specjalna wartość o nazwie NaN (ang. not a number - nieliczba), która oznacza błąd i której można używać do sygnalizowania błędów. W niektórych językach programowania, takich jak Perl i Tel, dowolną liczbę wartości można zgrupować w tzw. krotce (ang. tupie). Dzięki temu „taniemu" mechanizmowi jest moż liwe zwracanie wartości funkcji i informacji o błędach w jednym pakiecie. W bibliotece STL języka C+ + istnieje typ danych o nazwie pai r, którego można używać w podobny sposób. Różne wyjątkowe wartości, np. koniec pliku i stan błędu, najlepiej jest w jakiś sposób roz dzielić, zamiast używać do ich prezentacji jednej wartości. Jeśli nie da się łatwo dokonać takie go rozdziału, to można zastosować rozwiązanie polegające na zwracaniu jednej wartości „wy jątkowej" i dodaniu funkcji dostarczającej szczegółowych informacji o ostatnim błędzie. Takie podejście zastosowano w systemie Unix i bibliotece standardowej języka C, w któ rych wiele wywołań systemowych i funkcji bibliotecznych zwraca wartość -1, lecz jednocześnie koduje w specjalnej zmiennej globalnej o nazwie errno informację o błędzie, który wystąpił. Funkcja strerror zwraca łańcuch odpowiadający numerowi wykrytego błędu. W naszym sys temie poniższy program:
#i ncl ude #i ncl ude #i ncl ude #i ncl ude