Det här materialet, som ursprungligen skrevs 1994 för kursen Programmering i C vid Linköpings universitet, utgör en kort beskrivning av några aspekter på hur man programmerar. Bland annat handlar det om vad man brukar kalla "kodningsstil". Tänkt målgrupp är främst studenter på inledande eller senare programmeringskurser.
Mycket är mina personliga synpunkter, och det finns ibland alternativ till vad jag skrivit.
Jag har använt C i exemplen, men det mesta som står här gäller vare sig man programmerar i C eller i något annat språk, som till exempel Java eller Lisp. Exemplen kan lätt översättas.
Se upp med att en del av "C-koden" inte är helt korrekt C. Bland annat har jag använt svenska tecken i identifierare, vilket man inte får enligt C-standarden, och på ett par ställen är det lite pseudokod inblandad.
|
Jo:
Det är ganska ovanligt att en programmerare sätter sig ner med en tom fil och börjar skriva ett nytt program helt från början. I stället bygger man vidare på existerande program, eller modifierar och rättar dem. Därför består "programmering" minst lika mycket av att läsa programkod som att skriva den.
Att programmera innebär därför att man kommunicerar, och inte i första hand med datorn utan i stället med andra människor. Datorn ska förstås också läsa programmet, när den kompilerar det, men det tar kanske en halv sekund. Andra programmerare kan komma att jobba vidare i dagar, månader och år med din programkod. Då gäller de att de begriper vad du skrivit! (Och dessa "andra programmerare" kan också vara du själv, om två år när du glömt hur du tänkte när du skrev det där gamla programmet.)
Dessutom är det lättare att få programmet rätt om man skriver det på ett enkelt och tydligt sätt, utan onödiga krångligheter.
for (i = 0; i < antal_tecken; ++i) putchar(personens_namn[i]);Men om man har flera loopar i varandra kan det bli rörigt med loopvariablerna i, j, k och l, så hitta helst på "riktiga" namn då.
Förklara vad som händer. Exempel:
/* Den här funktionen sparar hela kundregistret på en fil. */Förklara hur där det behövs. Exempel:
/* Vi letar efter den sökta varan med binärsökning. */Förklara allt konstigt. Exempel:
/* Funktionen get_smurfs() returnerar en pekare till den * sist inlästa smurfen, så vi måste leta bakåt i listan * till den första posten innan vi skriver ut dem! */Särskilt viktigt är det att ange begränsningar och kända fel. Exempel:
/* Den här funktionen klarar bara att sortera arrayer med * högst 40 element. * Sorteringen är inte stabil, dvs element med lika * nycklar kan komma i fel ordning. */Man ska inte förklara på detaljnivå. Dåligt exempel:
i += 2; /* Öka i med 2 */Undvik kommentarer som redan är uppenbara av själva koden! Dåligt exempel:
int antal_kunder; /* Antal kunder */Saker som man nästan alltid bör kommentera:
|
De viktigaste kommentarerna är de översiktliga och förklarande, som beskriver vad ett stort kodavsnitt (kanske hela programmet) gör, och varför det finns och är skrivet på just det sättet.
char min_bils_märke[19];Bättre (i alla fall om man använder längden på bilmärken på fler ställen):
#define MAX_TECKEN_I_BILMäRKEN 18 char min_bils_märke[MAX_TECKEN_I_BILMäRKEN + 1];Ännu bättre kan det vara att införa en datatyp som heter bilmärke, som man sen kan använda:
bilmärke min_bils_märke;(Men av skäl som är speciella för hur språket C fungerar är det förmodligen olämpligt att låta datatypen utgöras av en array.)
Det här betyder inte att du aldrig ska använda globala variabler. Egentligen är det så att en variabel ska definieras "så nära där den används som möjligt", och att det inte ska gå att komma åt den på andra ställen i programmet.
En variabel som bara används inuti en funktion ska förstås vara lokal i den funktionen. I C kan man ha lokala variabler som behåller sitt värde mellan anrop (de deklareras som static).
Om antalet varv är känt redan när man går in i loopen, brukar man oftast använda en for-loop:
for (i = 0; i < antalet_varv_vi_ska_köra; ++i) gör_nånting();I C går array-index från 0 till antalet element i arrayen minus ett, och bland annat därför brukar man i loopar, som ska köras N stycken varv, alltid skriva
for (i = 0; i < N; ++i) gör_nånting();i stället för till exempel
for (i = 1; i <= N; ++i) gör_nånting();Om vi inte från början vet hur många varv loopen ska köras, kan man använda en while-loop:
while (det_är_inte_slut_än(nånting)) gör_nånting();En del C-programmerare använder for-loopar även i det här fallet, vilket går bra eftersom varje while-loop i C enkelt kan skrivas om som en for-loop (och tvärtom), vilket inte är sant i till exempel Pascal. Ett bra exempel är när man vill stega igenom andra linjära datastrukturer än arrayer, till exempel en länkad lista:
for (p = min_lista; p != NULL; p = p->next) skriv_element(p);Om loopen alltid ska köras minst en gång, och vi vill köra loopen ett varv innan testen, kan man använda en do-while-loop:
do gör_nånting(); while (det_är_inte_slut_än(nånting));Du kommer antagligen aldrig att behöva använda continue-satsen. Den är onödig och rör bara till koden.
Försök undvika break-satsen i loopar. Vid vissa tillfällen är den dock användbar och ger tydligare kod.
Tumregel:
Det ska gå att beskriva vad funktionen gör på en enda rad, exempelvis "sortera en array av heltal i nummerordning". Om beskrivningen i stället blir någonting i stil med "sortera en array av heltal i nummerordning, och skriv sen ut den, men om flaggan foo är satt ska den först summeras och summan läggas i variabeln fum, och sen..." har du antagligen lagt in för mycket saker i funktionen som egentligen inte hör ihop. Dela i så fall upp funktionen i dess logiska delar!
Ibland kan man åstadkomma detta genom att bryta ut den upprepade koden och lägga i en funktion, som sen anropas från flera ställen, och ibland räcker det med lite omflyttning. Om man till exempel har programsnutten
if (y == 0) { skriv_summan(x, y); skriv_differensen(x, y); skriv_produkten(x, y); printf("Går inte att dela med noll!\n"); } else { skriv_summan(x, y); skriv_differensen(x, y); skriv_produkten(x, y); skriv_kvoten(x, y); }med upprepningar av anropen till skriv_summan med flera, så kan man göra om den till
skriv_summan(x, y); skriv_differensen(x, y); skriv_produkten(x, y); if (y == 0) printf("Går inte att dela med noll!\n"); else skriv_kvoten(x, y);
Ett exempel där goto-satsen kan användas:
for (x = 0; x < x_max; ++x) for (y = 0; y < y_max; ++y) for (z = 0; z < z_max; ++z) for (t = 0; t < t_max; ++t) if (gör_nånting(x, y, z, t) == FELKOD) goto det_blev_fel; printf("Det gick bra.\n"); ... det_blev_fel: printf("Ajajaj. Det där gick inte bra.\n"); ...En del C-programmerare tycker också att man ska vara försiktig med return-satsen, eftersom den ju fungerar som ett "hopp" ut ur en funktion.
Som exempel kommer här tre olika sätt att skriva en if-sats:
if (nånting > nånting_annat) { gör_nånting(); och_en_sak_till(); } if (nånting > nånting_annat) { gör_nånting(); och_en_sak_till(); } if (nånting > nånting_annat) { gör_nånting(); och_en_sak_till(); }Det finns program som indenterar och formaterar koden åt dig. Till exempel kan du skriva ESC-X indent-region i Emacs, eller köra programmet indent på Unix, men inget formateringsprogram kan göra ett lika bra jobb som du själv. Använd mellanslag och tomrader för att öka läsbarheten genom att gruppera och dela upp ditt program. Dåligt exempel:
if(x*y>.1&&(z+2*w<0||z+2*w>10))Det blir ofta bättre med några strategiskt utplacerade mellanslag. Bra exempel:
if (x*y > 0.1 && (z+2*w < 0 || z+2*w > 10))Men stoppa inte heller bara planlöst in en massa mellanslag överallt! Dåligt exempel:
if ( x * y > 0.1 && ( z + 2 * w < 0 || z + 2 * w > 10 ) )Var noggrann! Om det plötsligt kommer ett par tomma rader mitt i en massa kod, så tror man förstås att det är nån sorts gräns mellan olika delar av programmet. Det kan bli förvirrande om det i stället beror på att du tryckte på RETUR några gånger av misstag och sen inte orkade ta bort de extra raderna.
Säg vad du menar, och krångla inte till det! Många saker går att skriva på flera olika sätt. Välj då det som tydligast visar hur du tänkt. Till exempel så är
if (antal_bilar == 0) { gör nånting... }bättre än
if (!antal_bilar) { gör nånting... }eftersom det du gör är ju att kolla om antalet bilar är noll, inte nån sorts logisk operation "om inte antalet bilar, så..."!
#define MAX_ANTAL_SMURFER 22 #define SMURFA(a, b) { while (a) smurfa(b); }"Funktionsliknande" makron brukar man dock ofta skriva med små bokstäver:
#define addera(x, y) ((x)+(y))Ofta skriver man också datatyper som deklarerats med typedef med stora bokstäver:
typedef unsigned int SMURFNUMMER;
En del programmerare lägger ner stor tid på att skriva "effektiva" program, dvs (oftast) program som går snabbt att köra. Vi brukar kalla detta att optimera, eller hand-optimera, sitt program.
Om man anstränger sig för att skriva snabba program, har man ju infört en extra svårighet, förutom att få programmet att fungera. Programmet blir krångligt och svårläst, eftersom man ofta ägnar sig åt olika "trix och fix" för att öka snabbheten. Det är också i de flesta fall onödigt att optimera, och även i de fall där det är motiverat är det lätt att man optimerar på fel sätt, kanske till och med så att programmet går långsammare!
Ytterligare en nackdel med att optimera sina program är att de trix och fix man använder kanske inte alls fungerar på andra datorer och andra kompilatorer än just den du utvecklar programmet i, så om ditt program senare ska köras på en annan dator, så kanske det måste skrivas om.
Därför ska vi nu lära oss två regler för optimering:
Se först till att programmet är korrekt (dvs att det fungerar) och robust (dvs att det klarar även felaktiga och konstiga inmatningar utan att få spader eller krascha), samt att att koden är lättbegriplig. Optimera inte!
Om du sen tycker att programmet går för långsamt, låt kompilatorn försöka generera effektivare kod. Kompilera om programmet med flaggan -O eller motsvarande. Optimera inte - låt kompilatorn göra det!
Om det i alla fall går för långsamt, kan du börja tänka på att optimera för hand. Gör då följande:
Det förekommer att C-programmerare skriver om array-kopieringar, som vanligen ser ut som
int array1[ANTAL_ELEMENT], array2[ANTAL_ELEMENT]; ... int i; ... for (i = 0; i < ANTAL_ELEMENT; ++i) array1[i] = array2[i];till någonting i stil med
int array1[ANTAL_ELEMENT], array2[ANTAL_ELEMENT]; ... register int *array1p, *array1end, *array2p; ... array1p = array1; array1end = array1 + ANTAL_ELEMENT; array2p = array2; while (array1p < array1end) *array1p++ = *array2p++;Den senare versionen antar de ska gå snabbare. I själva verket kommer en del kompilatorer att generera exakt samma kod för båda skrivsätten, och en del kompilatorer faktiskt långsammare kod för den andra konstruktionen. Om man dessutom tänker på att de komplicerat programmet i onödan så det tar längre tid att skriva, och med större risk för fel, och att programmet blir svårare för andra (och dem själva) att förstå, och att tiden för att köra den här loopen ändå kanske bara utgör en hundradels procent av exekveringstiden för hela programmet, kan man undra vad vitsen egentligen är.
Det här betyder inte att man alltid ska strunta i all effektivitet. Även ett enkelt program kan göras så långsamt och minneskrävande att det blir helt oanvändbart, om man bara väljer olämpliga datastrukturer och långsamma algoritmer. Du bör redan från början välja datastrukturer och algoritmer som passar för problemet. Lågnivåeffektiviteten är dock mindre viktig, och hanteras oftast bättre av kompilatorn.
Sammanfattning (som gäller både vid optimering och annars):
|
Portabilitet kan alltså vara arbetsbesparande, och bör eftersträvas. Den åstadkommes främst genom att man skriver vad man menar, och undviker maskin- eller systemberoende optimeringar och andra "trix och fix". Undvik också att skriva program som är beroende av saker som egentligen är odefinierade i språket du skriver i, men som ändå alltid brukar fungera på samma sätt just i den miljö du utvecklar programmet, till exempel beräkningsordning i uttryck, eller interna lagringsformat.
Om man vill ha ett program som är lätt att flytta till andra system, bör man dela upp programmet så att all den programkod som måste ändras finns samlad på ett ställe.
När vi skriver ett program kan vi till exempel dela upp det i flera delar, och sen kan vi skriva varje del av programmet för sig. Vi kontrollerar att varje del fungerar som den ska, och sen slår vi ihop alla delarna till ett (förhoppningsvis) fungerande program.
Man brukar tala om att varje modul ska ha hög "styrka" och att "kopplingen" mellan modulerna ska vara låg. Det betyder ungefär att varje modul ska göra en sak (men den enda saken kan vara komplicerad), och att den inte ska vara beroende av hur andra moduler ser ut inuti. Jämför med avsnittet "Dela upp programmet i funktioner!".
Ytterligare en fördel med modularisering är att du kan återanvända en modul du skrivit, till exempel genom att använda den till flera olika saker i samma program, eller kanske i andra program som du skriver senare.
Man kan se en abstrakt datatyp som ett sorts skal. Innanför skalet finns alla implementationsdetaljerna, till exempel om dataobjekten lagras som arrayer eller poster, och hur de funktioner som arbetar med dem är skrivna. Utanför skalet varken vill eller får man bry sig om dessa detaljer.
Exempel på en abstrakt datatyp kan vara en "mängd av heltal". Det spelar ingen roll för oss som använder datatypen "mängd av heltal" om den egentligen är en länkad lista, en array eller något annat, det enda vi bryr oss om är att den fungerar som en mängd, till exempel så att vi kan beräkna unionen eller snittet av två mängder.
De funktioner som arbetar med den abstrakta datatypens inre struktur kallar vi "primitiva funktioner" eller "primitiver".
Det kan finnas flera abstraktionsnivåer, inte bara en. En abstrakt datatyp kan implementeras med hjälp av andra abstrakta datatyper - till exempel klockslag som består av en timme och en minut. I de primitiva funktionerna för datatypen klockslag, till exempel skapa_klockslag eller är_detta_ett_klockslag måste man förstås känna till och använda att ett klockslag består av en timme och en minut som sitter ihop på något sätt, kanske i en lista eller an array. Men i klockslags-primitiverna är ju timme och minut fortfarande abstrakta datatyper, som ska hanteras endast via sina primitiver, så där får man inte gå in och gräva med pekare (eller med car och cdr, om vi får prata Lisp ett tag).