Unix ja shell-ohjelmointi, tentti 27.4.2001: Unix ja shell-ohjelmointi, tentti 27.4.2001: arvosteluperusteista, malliratkaisuja

Yleistä:

Seuraavassa "pikkuvirhe" tarkoittaa "puolen pisteen" virhettä, josta ei sakotettu jos niitä oli tehtävässä vain yksi. Tällaisia olivat mm. joidenkin erikoistapausten käsittely väärin (kuten lainausmerkkien puuttuminen muuttujan ympäriltä vaikka siinä voi olla erikoismerkkejä), eksoottisimmista ei mennyt virhettä lainkaan (erikoismerkkejä sisältävien tiedostonimien käsittelyä oikein ei vaadittu kuin tehtävässä 4).

Pikkuvirheeksi katsottiin myös syötön lukeminen väärästä paikasta (stdin vs. komentorivi) ym

Vähintään kokonainen piste meni kaikesta joka vaati skriptin muuttamista että se toimisi lainkaan (syntaksivirheistä jne)..

Piste meni myös Gnu-spesifeihin ominaisuuksiin olennaisesti nojautuvissa ratkaisuissa (kuten tail -qn1 "$@" jne): tehtäväpaperissakin sanottiin näin:

Huom. Ratkaisuissa saa käyttää vain luennoilla esiteltyjä komentoja, ei perliä tai komentojen Gnu- tai Linux-spesifisiä ominaisuuksia.

Gnu-spesifisten ominaisuuksien käyttö silloin kun niillä ei ollut olennaista merkitystä (kuten tail --lines 1) katsottiin pikkuvirheeksi.

Ylimääräisistä koristuksista tai turhan monimutkaisista ratkaisuista ei sakotettu.

Seuraavassa yksityiskohtia tehtävittäin (alkuperäiset kysymykset kursivoituna):

  1. Kirjoita skripti, joka

    1. tulostaa tiedostosta rivit, joissa on pariton määrä tuplalainausmerkkejä ("). (2p)

      Helpoin ratkaisu on varmaankin awk: määrittelemällä kenttäerottimeksi lainausmerkki riittää kun laskee kenttien määrän (pariton määrä kenttäerottimia tarkoittaa parillista määrää kenttiä). Siis esim. näin:

      #! /bin/sh
      awk -F\" 'NF%2==0' "$@"
      
      Lainausmerkit voi myös laskea vaikka gsub()-funktiolla (korvaten lainausmerkin itsellään), esim. näin:
      #! /usr/bin/awk -f
      gsub(/"/,"&")%2
      
      Kovin vaikeaa tämä ei ole grepilläkään: määritellään malli jossa on mielivaltainen määrä kaksi lainausmerkkiä sisältäviä jonoja ja sitten yksi jossa on tasan yksi sellainen (huom. ankkurit tai -x tarvitaan):
      #! /bin/sh
      grep -E '^([^"]*"[^"]*")*[^"]*"[^"]*$' "$@"
      
      tai
      #! /bin/sh
      grep -xE '([^"]*"[^"]*")*[^"]*"[^"]*' "$@"
      

      Myös sed sopii tarkoitukseen: talletetaan rivi ensin, sitten poistetaan kaikki parilliset lainausmerkit ja jos niitä jää tasan yksi, palautetaan ja tulostetaan alkuperäinen rivi:

      #! /usr/bin/sed -f
      h
      s/"[^"]*"//g
      /"/!d
      g
      
      Tässä sai yhden pisteen jos vastaus oli korjattavissa yhdellä muutoksella, esim. jos se löysi rivit joilla on parillinen määrä lainausmerkkejä tai jos grep-mallista puuttui ankkurointi (^$ tai -x).

    2. lukee stdinistä amerikkalaistyylisen MM/DD/YY -päiväyksen ja muuttaa sen suomalaiseen DD.MM.YY -muotoon. Vuosiluku voi olla kaksi- tai nelinumeroinen ja päivässä ja kuukaudessa voi olla tai olla olematta alkunollia. Päivämäärän oikeellisuutta ei tarvitse tarkistaa. (2p)

      Tässä tarvitsi vain vaihtaa kauttaviivat pisteiksi ja kuukauden ja päivän järjestys. Nelinumeroisen vuoden lyhentämistä kaksinumeroiseksi ei vaadittu (toki sallittiin), riitti että se tulee oikeaan paikkaan alkuperäisessä muodossa.

      Tässäkin ehkä helpoin on awk, esim:

      #! /bin/sh
      awk -F/ '{print $2,$1,$3}' OFS=.
      
      tai
      #! /usr/bin/awk -f
      BEGIN{FS="/" }
      { printf "%s.%s.%s\n", $2,$1,$3 }
      
      Lähes yhtä helposti se käy sedillä:
      #! /usr/bin/sed -f
      s-\(.*\)/\(.*\)/\(.*\)-\2.\1.\3-
      
      tai read/printf-komennoilla (myös echo kelpasi koska syötön tarkistusta ei vaadittu):
      #! /bin/sh
      IFS=/ read month day year
      printf "%d.%d.%d\n" $day $month $year
      
      Yhden pisteen sai pelkästä erotinmerkkien vaihtamisesta tai yhdellä muutoksella korjattavissa olevasta virheestä.

    3. tulostaa annettujen tiedostojen viimeiset rivit (argumenttina tiedostonimet, tulostuksena vain kunkin tiedoston viimeinen rivi). (2p)

      Tässä yksinkertaisin ratkaisu on tail -n 1 jokaiselle tiedostolle erikseen:

      #! /bin/sh
      for i ; do tail -n 1 "$i"; done
      
      Myös vanha syntaksi tail -1 kelpasi. Huom. standardi-tail ei osaa käsitellä kuin yhden tiedoston!

      Myös sediä voi käyttää, mutta se ei paljoa asiaa helpota: sedissä $ vastaa vain viimeisen tiedoston viimeistä riviä jos tiedostoja on monta, joten silmukka tarvitaan kuitenkin:

      #! /bin/sh
      for i ; do sed '$!d' "$i"; done
      

      Paljon helpompi ei ole myöskään awk, vaikka sillä tiedoston vaihtumisen voikin havaita:

      #! /usr/bin/awk -f
      f && f != FILENAME { print line }
      { line=$0; f=FILENAME }
      END { print line }
      
      Tiedostojen luettavuutta tai tyyppiä ei tarvinnut tarkistaa, eikä epäonnistuneistakaan tarkistusyrityksistä menettänyt pisteitä.

      Tässä ei myöskään tarvinnut miettiä mitä tehdä jos tiedoston lopusta puuttuu rivinvaihto, eikä ylimääräisen tyhjän rivin tulostamisesta tiedostojen väliin sakotettu.

      Gnu-spesifisistä ratkaisuista tyyliin tail -qn 1 "$@" sai yhden pisteen, pelkästään yhden tiedoston kanssa toimivasta ei yhtään.

  2. Kirjoita skripti, joka

    1. laskee annetun hakemistopuun (rekursiivisesti argumenttina annetun hakemiston alta) tavallisten tiedostojen lukumäärän ja keskimääräisen koon tavuina kahden desimaalin tarkkuudella. (3p)

      Tässä helpoimmalla päässee ls -Rl:n ja awkin kanssa. Pistealkuisten tiedostojen laskemiseksi tarvitaan ls:lle myös optio -a ja rivinvaihtoja sisältävien tiedostonimien varalta -q; kumpaakaan ei kuitenkaan vaadittu:

      #! /bin/sh
      ls -Rlaq "$@" |
      awk '/^-/ {t+=$5; n++} END{printf "count %d, avg. size %.2f\n",n,t/n}'
      
      Tässä find on turha koska ehto oli noin yksinkertainen ja tiedostojen koon saamiseksi tarvitaan kuitenkin ls -l, mutta toki sekin toimii:
      #! /bin/sh
      find "$@" -type f -exec ls -lq {} \; |
      awk '{t+=$5} END{printf "count %d, avg. size %.2f\n",NR,t/NR}'
      
      Myös shellin aritmetiikan käyttö awkin asemesta käy, kunhan muistaa että siinä on vain kokonaisluvut (ja että toisin kuin awkin, printf-komento ei tunne %f-formaattia (Gnu-versio kylläkin)), ja että standardishellissä putkitus silmukalle luo alishellin. Tämä toimii vain ksh:lla (kelpasi jos se oli huomattu):
      #! /bin/ksh
      count=0
      sum=0
      ls -Rlaq "$@" | grep '^-' |
      while read perm links user group size junk ;do
        count=$((count+1))
        sum=$((sum+size))
      done
      avg=$((sum*100/count))
      echo "$count files, avg. ${avg%??}.${avg#${avg%??}} bytes"
      
      Standardi-sh:lla (tai bashilla) asian voi hoitaa esim. näin:
      #! /bin/sh
      count=0
      sum=0
      ls -Rlaq "$@" | grep '^-' | (
        while read perm links user group size junk ;do
          count=$((count+1))
          sum=$((sum+size))
        done
        avg=$((sum*100/count))
        echo "$count files, avg. ${avg%??}.${avg#${avg%??}} bytes"
      )
      
      Myös aputiedoston käyttö käy, ja laskut voi tehdä bc:llä tai exprilläkin.

      Tässä meni piste jos hukkasi desimaalit, samoin Gnu-spesifisistä tempuista. Yhden pisteen sai jo lukumäärän laskemisesta oikein.

    2. lukee stdinistä tiedostonimiä, laskee kunkin tiedoston rivimäärän ja listaa ne pituusjärjestyksessä (tulostukseen kunkin tiedoston pituus riveinä ja tiedostonimi), lyhimmästä pisimpään. (3p)

      Yksinkertaisin hyväksyttävä ratkaisu oli tällainen:

      #! /bin/sh
      xargs -n 1 wc -l | sort -n
      
      Tuossa on se vika että se särkyy tyhjiä sisältävillä tiedostonimillä. Asian voi korjata esim. näin:
      #! /bin/sh
      while read -r filename; do
        wc -l <"$filename"
      done | sort -n
      
      Rivinvaihtoja sisältäviä tiedostonimiä ei voinutkaan käsitellä - stdinistä luettaessa rivinvaihtojen merkitystä ei voi tietää, ellei siihen ole erikseen sovittu jotakin. (Ilman -r -optiota read tulkitsee kenoviivan jatkorivin merkiksi, ja se hyväksyttiin ilman kommenttiakin.)

      Yleinen vika tässä oli option -n unohtaminen sort-komennosta, mistä meni yksi piste. Pikkuvirheestä kävi -g:n käyttö sen asemesta (se on vain Gnu sortissa).

  3. Kirjoita skripti, joka

    1. lajittelee amerikkalaistyylisiä hh:mm am/pm-kellonaikoja sisältävän tiedoston (yksi per rivi) aikajärjestykseen. Tunneissa ei ole alkunollia ja minuuttien jäljessä on tasan yksi tyhjä. (Huom. 12 am on puoliyö ja 12 pm keskipäivä; tuntia 0 ei ole.) Testaukseen ohessa on tiedosto ~/data/ustimes.txt ja sama lajiteltuna ~/data/ustimes.sorted. (3p)

      Helpointa lienee ensin lisätä alkunolla tarvittaessa ja vaihtaa 12 tunneissa 00:ksi, lajitella niin että am/pm on merkitsevin, ja lopuksi muuntaa 00:t takaisin ja poistaa turhat alkunollat:

      #! /bin/sh
      export LC_COLLATE=C
      sed -e 's/^.:/0&/' -e 's/^12/00/' "$@" | 
      sort -k 1.7 |
      sed -e 's/^00/12/' -e 's/^0//'
      
      LC_COLLATE:n asettamista ei vaadittu ("a"<"p" pitänee paikkansa kaikissa normaaleissa localeissa).

      Asian voi ratkaista myös erottelemalla aamu- ja iltapäivän esim. näin:

      #! /bin/sh
      {
      sed -n '/am/s/^12/00/p' "$@" | sort -n
      sed -n '/pm/s/^12/00/p' "$@" | sort -n
      } | sed 's/^00/12/'
      

      Huom. muutos pitää tehdä sekä aamu- että iltapäivällä: 12:30am = 00.30 ja 12:30pm = 13.30.

      Tässä meni yksi piste 12:n väärinkäsittelystä. Pelkästä sort-komennosta ei saanut pisteitä.

    2. etsii annetun käyttäjän kotihakemiston alta rekursiivisesti kaikki tiedostot, joihin on kirjoitusoikeus sellaisella ryhmällä, johon ko. käyttäjä ei kuulu. (3p)

      Tämä käy esim. näin:

      #! /bin/sh 
      eval homedir=~$1
      groups=$(id -G $1 |
        sed -e 's/[^0-9]/ /g' -e 's/\([0-9][0-9]*\)/-o -group \1/g' -e 's/^-o//' )
      find "$homedir" -perm -g=w ! \( $groups \) -print
      
      Käyttäjä voi kuulua moneen ryhmään; jos tämä jäi huomaamatta, menetti yhden pisteen; melkein onnistuneesta yrityksestä selvisi puolen pisteen menetyksellä. Ryhmien samoin kuin kotihakemiston selvittäminen /etc/passwd- ja /etc/group-tiedostoja lukemalla kelpasi vaikkei olekaan tiukasti standardinmukaista (jos se oli tehty oikein).

      Epästandardin groups-komennon käyttö katsottiin tässä pikkuvirheeksi, jos sitä oli käytetty oikein.

      Tulostusformaatiksi hyväksyttiin melkein mitä tahansa, myös tiedostonimi ilman hakemistotietoa.

  4. Kirjoita skripti, joka etsii annetusta hakemistopuusta rekursiivisesti ei-siirrettävät tiedostonimet ja muuttaa ne siirrettäviksi. Siirrettävässä nimessä saa olla vain ASCII-kirjaimia (a-z, A-Z), numeroita 0-9, pisteitä tai tavu- tai alaviivoja, paitsi ensimmäinen ei saa olla tavuviiva, ja se saa olla enintään 14 merkkiä pitkä. Virheelliset merkit nimissä pitää muuttaa alaviivoiksi ja ylipitkät nimet katkaista. Jos muuten syntyisi useita samannimisiä, loppuun pitää lisätä numeroita (tarvittaessa lyhentäen ensin). Lisäksi sen pitää tuntea optiot -r scriptfile, jolla se kirjoittaa tiedostoon scriptfile skriptin jolla nimet voi muuttaa takaisin, sekä -t jolla se vain tulostaa mitä tekisi muttei muuta mitään. (6p)

    Tässä olennaista oli nimenomaan erikoismerkkien käsittely, ja esim. echon käytöstä, virheistä kenoviivan kanssa malleissa tms meni pisteitä. Rivinvaihtoja sisältävien tiedostonimien kanssa epäonnistuminen annettiin kuitenkin anteeksi.

    Hakemistojen käsittelyä ei vaadittu, ei edes varautumista hakemistonimissä oleviin erikoismerkkeihin, tehtävä oli siinä ehkä tulkinnanvarainen.

    Optioiden puuttumisesta meni vain yksi piste, ja osittaisesta käsittelystä puolikas.

    Keskeneräisestä 'hyvästä yrityksestä' saattoi saada yhden tai kaksi pistettä.

    Rivinvaihtoja sisältävien nimien käsittely lienee suurin vaikeus, se onnistuu helpoiten käyttämällä kahta skriptiä: ensimmäinen ("4") käsittelee optiot ja etsii muutettavat tiedostonimet ja kutsuu sitten toista find ... -execillä (huomaa että funktiota ei voi sillä tavoin kutsua, siksi tarvitaan toinen skripti):

    #! /bin/sh 
    export MOVE=mv
    scriptfile=/dev/null
    while getopts :tr: opt ;do
        case $opt in
    	t) MOVE=fakemv ;;
    	r) scriptfile="$OPTARG" ;;
    	?) printf "Usage %s: [-t] [-r scriptfile] dir\n" "${0##*/}" ; exit 1 ;;
        esac
    done
    shift $((OPTIND - 1))
    
    find "$*" -type f \( -name '*[!-a-zA-Z0-9_.]*' -o -name '-*' \
    	-o -name '???????????????*' \) -exec "$0"-1 {} \;  3>"$scriptfile"
    

    Toinen ("4-1") saa sitten argumenttinaan muutettavan tiedostonimen, sekä ympäristömuuttujassa MOVE joko "mv" tai "fakemv" option -t mukaan, sekä tiedostokahvan 3 suunnattuna palautusskriptitiedostoon:

    #! /bin/sh 
    
    fakemv() {
        printf 'mv %s %s %s\n' "$1" "$2" "$3"
        rm "$3"
    }
    
    badpath="$*"
    badname="${badpath##*/}"
    dir="${badpath%/*}"
    
    newname=$(printf "%s\n" "$badname" | 
         tr -c 'a-zA-Z0-9_.\n-' '[_*]' |
         sed -e 's/^-/_/' -e 's/\(.\{14\}\).*/\1/')
    
    set -C
    count=0
    until >"$dir/$newname" 2>/dev/null ;do
        count=$((count+1))
        tmp=$newname
        newname=$newname$count
        while [ ${#newname} -gt 14 ] ;do
          tmp=${tmp%?}      
          newname=$tmp$count
        done
    done
    
    ${MOVE:-fakemv} -f "$badpath" "$dir/$newname" 
    
    printf "mv %s '" "$dir/$newname" >&3
    printf "%s\n" "$badpath" | sed -e "s/'/'\\\\''/g" -e "\$s/\$/'/" >&3
    
    Tuosta on jätetty kaikki ylimääräiset virhetarkistukset pois (mistä seuraa mm. että se joutuu päättymättömään silmukkaan jos johonkin hakemistoon ei ole kirjoitusoikeutta...)

    Huomaa option -r toteutus, erityisesti lainausmerkkien ja rivinvaihtojen käsittely.

    Ohessa on viimeistellympi versio (fixnames), joka tekee saman asian yhdellä skriptillä, hoitaa myös hakemistonimet ja käsittelee virhetilanteita paremmin.

  5. Kahdessa tiedostossa on aikaleimattuja rivejä muotoa MMM DD hh:mm:ss ...
    missä MMM on kuukauden kolmikirjaiminen englanninkielinen lyhenne, DD kuukaudenpäivä, hh:mm:ss kellonaika ja ... mielivaltaista tekstiä. Rivit ovat aikajärjestyksessä (kummassakin tiedostossa). Vuosilukuja ei ole, mutta kummassakin esiintyy samat vuodet ja ainakin yksi rivi jokaisesta niissä esiintyvästä vuodesta.
    Kirjoita skripti, joka yhdistää ko. tiedostot siten, että rivit pysyvät aikajärjestyksessä. Testaukseen voit käyttää oheisia tiedostoja ~/data/log1.txt ja ~/data/log2.txt ja niistä haluttua tulosta ~/data/logs.merged. (6p)

    Tässä varsinainen ongelma oli vuosilukujen puuttuminen: tarkoitus oli, että kun aikaleimojen välissä on aina enintään vuosi, niin vuoden vaihtuminen huomataan siitä, että päiväys muuten menisi taaksepäin. Toinen vaikeus on kuukausien vertailu: standardi-sort ei tunne Gnu sortin optiota M, joka vertailee kuukausia suoraan (sen käytöstä meni piste).

    Yksinkertaisin ratkaisu lienee ensin lisätä kumpaankin tiedostoon vuosiluku ja kuukausi numeerisessa muodossa, lajitella tulos yhteen ja poistaa lisäykset, esim. näin:

    #! /bin/sh 
    
    TMP=/tmp/.log1.tmp.$$
    
    fixdate() {
     awk 'BEGIN{ year=lastmonth=0
            Mon["Jan"]=1;  Mon["Feb"]=2;  Mon["Mar"]=3;  Mon["Apr"]=4;
            Mon["May"]=5;  Mon["Jun"]=6;  Mon["Jul"]=7;  Mon["Aug"]=8;
            Mon["Sep"]=9;  Mon["Oct"]=10; Mon["Nov"]=11; Mon["Dec"]=12
          }
          Mon[$1] < lastmonth { ++year }
          { lastmonth=Mon[$1] 
            print year, Mon[$1], $0 }
     ' $1
    }
    
    fixdate $1 > "$TMP"
    fixdate $2 |
    sort -m -k1,1n -k2,2n -k 4,4n -k 5,5 "$TMP" - | cut -d' ' -f 3-
    
    rm "$TMP" 
    
    Huomaa option -m käyttö sort-komennossa: se nopeuttaa lajittelua merkittävästi kun tiedostojen tiedetään olevan ennestään järjestyksessä (vaikka toisaalta aiheuttaa aputiedoston käytön tarpeen). (Sitä ei kuitenkaan edellytetty.)

    Myös tiedostojen lukeminen rinnakkain silmukassa ja kuukausien vertailu sitä mukaa onnistuu, mutta on olennaisesti hankalampaa.

    Tässä pikkuvirheiksi tulkittiin ilmeisiä syntaksivirheitäkin, toimimattomasta saattoi saada neljäkin pistettä jos idea oli oikein ja se oli helposti korjattavissa, ja 1-2 pistettä saattoi saada hyvästä yrityksestä.


File translated from TEX by TTH, version 1.98.
On 18 May 2001, 17:17.