Tieto vs kontrolli (struct ja enum)

Tässä kohdassa on tarkoitus puhua tieto-ohjatusta ohjelmoinnista. Sitä ennen esittelemme kuitenkin kaksi C++:n tietotyyppiä, joita asian selvittämiseksi tarvitaan.

Tietuetyyppi (struct)

Monissa ohjelmointikielissä ohjelmoijan on mahdollista määritellä omia tietotyyppejään, joiden avulla useita keskenään erityyppisiä tietoalkioita voidaan koota yhteen. Tavallaan kyseessä on eräänlainen alkeellisempi versio luokkatyypeistä: Mitään funktiorajapintaa ei muodostu, vaan osatietoja (kenttiä) käsitellään suoraan. Tällaista tietotyyppiä kutsutaan usein tietueeksi (record) tai C++:ssa struct:ksi.

C++:ssa tietue määritellään varatun sanan struct avulla:

struct Tuote {
    string tuotenimi;
    double hinta;
};

Edellisen määrittelyn jälkeen uutta tyyppiä Tuote voidaan käyttää hyvin pitkälle samoin kuin muitakin tietotyyppejä:

Tuote tavara = {"saippua", 1.23};

Tuote-tyyppisen muuttujan yksittäisiin osatietoihin päästään käsiksi .-operaattorilla:

tavara.hinta = 0.9 * tavara.hinta;  // Alennus 10 %
cout << tavara.tuotenimi << ": " << tavara.hinta << endl;
tavara = { "appelsiini", 0.45 };

Tietuetta voi luonnollisesti käyttää myös STL-säiliöön talletettavana arvona, funktion parametrina (arvo- tai viiteparametrina) ja paluuarvona.

Tietue voi kuvata esimerkiksi yksittäisen henkilön:

struct Henkilo {
    string etunimi;
    string sukunimi;
    string osoite;
    string sahkoposti;
};

Yllä olevan tietueen kaikki kentät ovat samaa tyyppiä, joten yllä olevat tiedot olisi voitu kirjoittaa 4-alkioiseen vektoriin. Tietue on kuitenkin tässä parempi (ja se oikea) ratkaisu, koska nyt voidaan käyttää nimettyjä kenttiä indeksien sijasta. Vektoriin ei pidä tallettaa merkitykseltään erilaisia tietoja, vaikka niiden tyyppi olisi sama.

Luettelotyyppi

Joskus tulee tarve muuttujalle, jolla voi olla vain tiettyjä arvoja, esim. kirjaston ohjelmistossa kirjan tila voi olla hyllyssä, lainassa, varattu tai vaikka hukassa. Olisi tietenkin mahdollista käyttää tähän tarkoitukseen string- tai vaikka int-tyyppejä, mutta ne sallisivat paljon muitakin vaihtoehtoja. Siksi ne eivät ole parhaita mahdollisia kirjan tilan tallentamiseen.

Luettelotyyppi on tietotyyppi (tai oikeastaan luokka), jonka kaikki mahdolliset arvot (alkiot) ovat ohjelmoijan määrittelemiä.

C++:ssa luettelotyyppi (luokka) määritellään seuraavasti:

enum class Tyypin_nimi { alkio_1, ... , alkio_n };

Yllä määriteltiin uusi tyyppi Tyypin_nimi, johon kuuluvat alkiot Tyypin_nimi::alkio_1, …, Tyypin_nimi::alkio_n. Konkreettisemmin:

enum class Kirjantila { HYLLYSSA, LAINASSA, VARATTU, HUKASSA };

Kun luettelotyypin arvoja käytetään, luokan nimi tarvitaan tarkentimeksi:

Kirjantila tila;
tila = Kirjantila::HYLLYSSA;

Luettelotyypit ovat usein käteviä, jos ohjelmassa on toisiinsa liittyviä vakioita. Sen sijaan, että kirjoittaisimme:

const int PUNAINEN = 0;
const int KELTAINEN = 1;
const int VIHREA = 2;

voidaan määritellä luettelotyyppi:

enum class LiikenneValo { PUNAINEN, KELTAINEN, VIHREA };

Joskus on tarvetta silmukalle, joka käy läpi kaikki luettelotyypin alkiot. Seuraava esimerkki näyttää, miten tämä onnistuu:

#include <iostream>

using namespace std;

// C++ liittää automaattisesti kokonaislukuarvon kullekin luettelotyypin
// alkiolle. Numerointi alkaa nollasta:
//     MA = 0, TI = 1, ... , SU = 6
// Kuitenkaan luettelotyypin ja int-tyypin välillä ei tapahdu
// automaattista tyypinmuunnosta. Se täytyy tehdä manuaalisesti
// käyttämällä static_castia.
enum class Viikonpaiva {
  // 0   1   2   3   4   5   6
    MA, TI, KE, TO, PE, LA, SU
};

// Koska luettelotyypin arvojen numerointi alkaa nollasta,
// viikonpäivien lukumäärä on SU (6) + 1.
const int PAIVIA_VIIKOSSA = static_cast<int>(Viikonpaiva::SU) + 1;

Viikonpaiva seuraava(Viikonpaiva paiva) {
    // Huomaatko, miksi tarvitaan jakojäännösoperaattoria (%),
    // kun lasketaan seuraava päivä?

    return static_cast<Viikonpaiva>((static_cast<int>(paiva) + 1) % PAIVIA_VIIKOSSA);
}

int main() {
    cout << "Viikossa on " << PAIVIA_VIIKOSSA << " päivää, ja ne ovat:" << endl;

    // Huomaa, että for-silmukan ehdon loppuosa on turha.
    // Nimittäin jokainen päivä on pienempi tai yhtäsuuri kuin SU,
    // sillä SU on "suurin" päivä. Silmukka lopetetaan break-komennolla,
    // kun päivä on SU. Tämä on ehkä makuasia, mutta for-silmukka
    // ei ehkä ole selvin vaihtoehto tällaisessa tilanteessa, kun
    // läpikäytävät arvot ovat syklisiä (päivän SU seuraaja on aina MA).

    for ( Viikonpaiva paiva = Viikonpaiva::MA; paiva <= Viikonpaiva::SU; paiva = seuraava(paiva) ) {
        cout << "Paivan numero " << static_cast<int>(paiva) << ": ";

        if ( paiva == Viikonpaiva::MA ) {
            cout << "Maanantai :( :(" << endl;

        } else if ( paiva == Viikonpaiva::TI ) {
            cout << "Tiistai :(" << endl;

        } else if ( paiva == Viikonpaiva::KE ) {
            cout << "Keskiviikko :(" << endl;

        } else if ( paiva == Viikonpaiva::TO ) {
            cout << "Torstai :|" << endl;

        } else if ( paiva == Viikonpaiva::PE ) {
            cout << "Perjantai :)" << endl;

        } else if ( paiva == Viikonpaiva::LA ) {
            cout << "Lauantai :) :)" << endl;

        } else if ( paiva == Viikonpaiva::SU ) {
            cout << "Sunnuntai :|" << endl;

            // Silmukka voidaan lopettaa, sillä koko viikko on tulostettu
            break;
        }
    }

    // Vaihtoehtoisesti voidaan käyttää myös while-silmukkaa:

    Viikonpaiva paiva = Viikonpaiva::MA;

    while ( true ) {
        cout << "Paivan numero " << static_cast<int>(paiva) << ": ";

        if ( paiva == Viikonpaiva::MA ) {
            cout << "Maanantai :( :(" << endl;

        } else if ( paiva == Viikonpaiva::TI ) {
            cout << "Tiistai :(" << endl;

        } else if ( paiva == Viikonpaiva::KE ) {
            cout << "Keskiviikko :(" << endl;

        } else if ( paiva == Viikonpaiva::TO ) {
            cout << "Torstai :|" << endl;

        } else if ( paiva == Viikonpaiva::PE ) {
            cout << "Perjantai :)" << endl;

        } else if ( paiva == Viikonpaiva::LA ) {
            cout << "Lauantai :) :)" << endl;

        } else if ( paiva == Viikonpaiva::SU ) {
            cout << "Sunnuntai :|" << endl;
        }

        paiva = seuraava(paiva);

        // Silmukka voidaan lopettaa, jos paiva on MA, sillä silloin
        // edellinen päivä oli SU

        if ( paiva == Viikonpaiva::MA ) {
            break;
        }
    }
}

Luettelotyyppiin ja silmukoihin liittyy vielä yksi tärkeä asia. Rajoitettu for-silmukka on hyödyllinen, jos halutaan käydä läpi vain osa viikonpäivistä:

for ( Viikonpaiva paiva: { Viikonpaiva::KE, Viikonpaiva::MA, Viikonpaiva::PE } ) {
    cout << "Viikonpaiva number is " << static_cast<int>(paiva) << endl;
}

Tieto-ohjattu ohjelmointi

Tietokoneohjelmat koostuvat tiedosta (data) ja niihin kohdistuvista käskyistä. Todellisuudessa tämä kahtiajako ei ole selvärajainen, vaan joissakin tapauksissa tieto voi korvata osan käskyistä tai päinvastoin. Tätä käytetään hyväksi nk. tieto-ohjatussa (data-driven / data-directed) suunnittelussa ja ohjelmoinnissa.

Tieto-ohjatun ohjelmoinnin idea on suunnitella ja toteuttaa ohjelmaan tietorakenteita, joiden avulla tietoa käsittelevien käskyjen määrä saadaan pienemmäksi, eli korvataan käskyjä tiedolla. Tämä tulee paremmin esiin esimerkissä, jossa käytetään edellä esiteltyjä struct- ja enum-tyyppejä. (Esimerkin koodi löytyy kokonaisuudessan Gitistä: examples/03/datadrivenprogramming).

Ajatellaan aluksi koodia, jossa ei ole hyödynnetty tieto-ohjatun ohjelmoinnin filosofiaa:

#include <string>

enum class PostalAbbr {AL, AK, AZ, AR, CA, CO, ERROR_CODE};  // Excluded the rest 44 elements

// Version 1: First idea
PostalAbbr name_to_abbreviation(const string& name)
{
    if(name == "Alabama"){
        return PostalAbbr::AL;
    } else if (name == "Alaska"){
        return PostalAbbr::AK;
    } else if (name == "Arizona"){
        return PostalAbbr::AZ;
    } else if (name == "Arkansas"){
        return PostalAbbr::AR;
    } else if (name == "California"){
        return PostalAbbr::CA;
    } else if (name == "Colorado"){
        return PostalAbbr::CO;
    } else {   // Excluded 44 "else if" blocks
        return PostalAbbr::ERROR_CODE;
    }
}

Yllä oleva if-lause olisi todellisuudessa paljon pidempi, siis jos myös loppuja 44 osavaltiota käsittelevä koodi olisi kirjoitettu näkyviin. Jos sama koodi kirjoitetaan soveltamalla tieto-ohjatun ohjelmoinnin periaatteita, päästään pitkästä if-lauseesta eroon seuraavasti:

#include <string>
#include <vector>

enum class PostalAbbr {AL, AK, AZ, AR, CA, CO, ERROR_CODE};  // Excluded the rest 44 elements

struct StateInfo {
    string name;
    PostalAbbr abbreviation;
};

const vector<StateInfo> STATES = {
    { "Alabama", PostalAbbr::AL },
    { "Alaska", PostalAbbr::AK },
    { "Arizona", PostalAbbr::AZ },
    { "Arkansas", PostalAbbr::AR },
    { "California", PostalAbbr::CA },
    { "Colorado", PostalAbbr::CO }  // Excluded 44 lines
};

// Version 2: Better solution
PostalAbbr name_to_abbreviation(const string& name)
{
    for(auto s : STATES) {
        if(name == s.name){
            return s.abbreviation;
        }
    }
    return PostalAbbr::ERROR_CODE;
}

Näiden kahden esimerkin ero on selvä: Kun nähtiin hiukan vaivaa sopivan tietorakenteen suunnitelussa ja alustamisessa, niin varsinainen name_to_abbreviation-funktio supistui minimaaliseksi. Tieto-ohjatussa ohjelmoinnissa toteutustekniikkana on aina (tavalla tai toisella) etukäteen prosessoitu informaatio, joka muodostuu lähtöarvoista ja niistä seuraavasta lopputuloksesta.

Tieto-ohjatun ohjelmoinnin hyötyjä ovat:

  1. Syntyvät ohjelmat ovat yleensä lyhyempiä, mutta niiden selkeys ei silti kärsi.

  2. Virheiden mahdollisuus pienenee.

  3. Ohjelmien laajennettavuus ja ylläpidettävyys on helpompaa.

Varma merkki tieto-ohjatun uudelleensuunnittelun tarpeesta ohjelmassa on liian pitkäksi kasvava if-rakenne (tai switch-rakenne, jota ei ole tähän mennessä käsitelty).