Genomgång av kodprovet 2016-12-21
Uppgift 1
Du har fått ut en implementation av ett binärt sökträd i tree.c och tree.h med tillhörande testfilen tree_test.c. Uppgiften är att implementera en stödfunktion för att testa binärträdet utan att bryta mot inkapsling. Koden skall skrivas i tree.c som är den enda fil som lämnas in.
Funktionen tree_path_for_key som du skall implementera genererar en stig från roten av ett träd till en nod med en viss nyckel. Denna stig representeras som en helt vanlig C-sträng vars ingående bokstäver är 'L' eller 'R'. Stigen ''LLRRL'' betyder ''från roten av trädet, gå vänster (Left) två gånger, sedan höger (Right) två gånger, och sedan vänster igen. Den tomma stigen avser roten (iom att vi ''står kvar där'').
Dokumentation för funktionen (alltså, specifikationen för vad du skall implementera, finns sist i tree.h).
Funktionen tree_key_for_path som finns i tree.c navigerar genom ett träd utefter en given stig genererad av ''din funktion'' och returnerar nyckeln för den nod som stigen leder till. Testerna i tree_test.c använder sig av båda dessa flitigt. När du har implementerat tree_key_for_path korrekt bör du kunna kompilera och köra tree_test.c utan att någon assert ''triggas''.
Om trädet
Det binära sökträdet fungerar analogt med det generella sökträdet från lagerhanteraren på kursen. Trädet består av ett antal noder som har pekare till sina vänster- och högerben, samt en pekare till en \emph{nyckel} som avgör sorteringen i trädet, samt ett värde. Värdet är förmodligen inte intressant i denna uppgift.
Både nyckeln och värdet är av typen void *, och trädet tar in en funktionspekare av typen cmp_func för att avgöra två nycklars inbördes sorteringsordning.
För att lösa uppgiften måste du kunna läsa C-koden, men det är långt i från all C-kod som är relevant att läsa. Främst måste du förstå hur man kan jämföra nycklar av void *-typ med hjälp av funktionspekaren som vi har skickat in. Alltså, kod som du har skrivit.
Kompilering och testning
Du kan kompilera programmet med make, köra tester med make test och köra testerna i valgrind med make memtest. Du måste inspektera utskrifterna på skärmen själv. Om make memtest inte ger någon output betyder det att programmet inte har några läckage enligt valgrind.
För att bli godkänd måste ditt program inte bara vara korrekt utan också fritt från minnesläckage, inte läsa oinitierat minne och inte skriva utanför allokerat minne eller skriva över data på stacken. Alla block allokerade av programmet skall vara explicit frigjorda i programmet innan det terminerar. Verifiera det med valgrind.
Observera att testerna i makefilen är till för att hjälpa dig att hitta fel i din kod och att programmet passerar testerna inte nödvändigtvis betyder att programmet är korrekt eller att du är godkänd.
Tips
När Elias testade den här uppgiften snubblade han på att *buffer[i] och (*buffer)[i] inte betyder samma sak. Det hade jag nog också gjort om jag inte i min facit-lösning använt pekararitmetik istället för arraynotation. Båda går precis lika bra att använda i uppgiften!
SPOILER ALERT! SPOILER ALERT! SPOILER ALERT! SPOILER ALERT!
Lösningsförslag
Tobias lösning
Följande lösning är Tobias lösningsförslag som förklaras med kommentarer i koden. Den är iterativ och använder pekararitmetik. För enkelhets skull diskuteras koden med kommentarer i kodblocket.
bool tree_path_for_key(tree *t, void *key, char **buffer)
{
/// t är trädet, n är dess rotnod
node *n = t->root;
/// Låt path och start båda peka på starten av den buffer
/// där strängen skall skrivas -- om en buffer inte gavs
/// av användaren skapas en mha malloc. Anledningen till
/// att vi behöver två pekare är för att vi flyttar path
/// till att peka på slutet av strängen hela tiden.
char *path = *buffer ? *buffer : malloc(tree_depth(t) + 1);
const char *start = path;
/// n är vår "cursor-variabel" som flyttar sig genom trädet
while (n)
{
/// Jämför key med den aktuella nodens key --
/// med hjälp av compare-funktionen i tree
const int cmp_result = t->key_cmp(key, n->key);
/// Gå vänster eller höger, och skriv L resp. R i strängen.
/// Flytta fram strängpekaren och flytta n i trädet.
if (cmp_result < 0)
{
*path++ = 'L';
n = n->left;
}
else if (cmp_result > 0)
{
*path++ = 'R';
n = n->right;
}
else
{
/// Vi har nått slutet av funktionen --
/// terminera strängen.
*path++ = '\0';
/// Om vi har skapat vår egen buffert skall
/// den returneras till användaren.
if (*buffer == NULL) *buffer = start;
return true;
}
}
return false;
}
Elias lösning
Elias lösning är rekursiv och använder sig av en hjälpfunktion, samt arrayer istället för pekararitmetik. För enkelhets skull diskuteras koden med kommentarer i kodblocket.
bool tree_path_for_key(tree *t, void *key, char **buffer)
{
bool own_buffer = false;
if (*buffer == NULL)
{
own_buffer = true;
*buffer = calloc(tree_depth(t) + 1, sizeof(char));
}
/// Anrop till funktion som gör själva jobbet och som returnerar
/// längden på strängen som skrevs in i buffer
int length = tree_internal_path_for_key(t->root, key,
buffer, 0, t->key_cmp);
/// \0-terminera strängen vid rätt längd
(*buffer)[length] = '\0';
/// Städa bort egen buffert om nyckeln inte fanns
if(length == 0 && own_buffer)
{
free(*buffer);
*buffer = NULL;
return false;
}
else
{
return true;
}
}
int tree_internal_path_for_key(node *node, void *key,
char **buffer, int i, cmp_func key_cmp)
{
/// Trillar vi ur trädet -- signalera att vi inte hittade nyckeln
if (!node)
{
return 0;
}
/// Gör jämförelsen med hjälp av jämförelsefunktionspekaren
const int cmp = key_cmp(key, node->key);
if (cmp < 0)
{
/// Bygg på strängen, öka i som håller antalet skrivna tecken
(*buffer)[i] = 'L';
return tree_internal_path_for_key(node->left, key,
buffer, i + 1, key_cmp);
}
else if (cmp > 0)
{
/// Bygg på strängen, öka i som håller antalet skrivna tecken
(*buffer)[i] = 'R';
return tree_internal_path_for_key(node->right, key,
buffer, i + 1, key_cmp);
}
else
{
/// Returnera i, antalet skrivna tecken
return i;
}
}
Genomgång av vanliga fel
Då glädjande många klarade C-delen på kodprovet föregående kodprovstillfälle var det väldigt få som skrev denna C-del. Det gör att det inte finns så många vanliga fel att prata om.
Det var ungefär lika delat mellan iterativa och rekursiva lösningar.
En aspekt av uppgiften som verkar ha varit svår är att path är en dubbelpekare, dvs. path pekar på en adress där adressen till bufferten där stigen skall skrivas ligger.
Anledningen till detta är för att det skall gå att ange NULL som adress till bufferten där stigen skall skrivas, och då få en sådan buffert skapad. Detta är analogt med hur t.ex. getline fungerar. Om jag anropat tree_path_for_key(t, k, &buf) och buf != NULL så kommer sigen skrivas så att jag kan printa resultatet med printf("%s", buf). Om buf == NULL skall funktionen tree_path_for_key skapa en ny buffert för stigen som användaren kan läsa genom buf.
Ett annat fel rör missförståelse av hur pekare till pekare fungerar. Om man får in path som en char ** kan vi använda *path = malloc(depth(...)) för att "skicka tillbaka" den nyallokerade bufferten till användaren. Det är viktigt att förstå att path pekar tillbaka på en plats på anroparens stack-frame! Om man t.ex. gör char *x = *path; char **y = &x: så pekar nu y på den aktuella stack-framen, på den adress där x ligger.
SLUT PÅ SPOILERS!
Uppgift 2
Obs! Lösningen till denna uppgift är inte så många rader kod -- svårigheten ligger i att förstå problemet och hur det kan lösas. Tips: rita för att förstå strukturen.
Villkorssatser som inspekterar typer i ett program är ofta av ondo. Ponera följande kod:
if (x instanceof Foo) {
((Foo) x).bar(42);
} else if (x instanceof Quux) {
((Quux) x).baz(42);
}
Detta program lider av att klasserna Foo och Quux är hårdkodade -- om vi vill lägga till ytterligare en möjlighet, t.ex. att x kan vara en instans av Frob måste vi gå in och utöka koden med en ny villkorssats. Det kanske inte är möjligt alltid, och visst skulle det vara smidigt om det ''bara fungerade''?
Ponera att vi istället hade en klasshierarki:
class Foo implements Fnord { ... }
class Quux implements Fnord { ... }
och interfacet Fnord har en metod doIt(). Om vi såg till att Foo:s doIt() gjorde motsvarande bar(42) och att Quux:s doIt() gjorde motsvarande baz(42) kunde vi skriva om koden så här:
x.doIt(42)
''Magin'' här ligger i att villkorssatsen har ersatts med ett anrop till en metod som binder dynamiskt beroende på om x under körning är en Foo eller en Quux. Koden blir som konsekvens av detta renare.
Denna uppgift går ut på att skriva klart en länkad lista så att den sista länken inte är en Link (som är given) utan en instans av klassen Sentinel -- ett bättre namn kanske hade varit LastLink. Denna klass måste du själv skriva från grunden. Denna klass skall vara skriven med vetskap om att dess instanser alltid är en tom länk, sist i en kedja av länkar, så att den rekursiva implementation av t.ex. size() som redan finns i Link inte behöver kontrollera om next är null eller inte -- det sista anropet i en uträkning av size() blir till en metod i en instans av klassen Sentinel, som omedelbart terminerar rekursionen (med rätt värde).
Implementera klassen Sentinel så att testprogrammet Driver fungerar. Du måste implementera size(), occurrences och remove -- utan att använda villkorssatser. Alltså, inga if, while, for-etc. i din kod. Alla beteenden som ibland gör si och ibland så skall implementeras med hjälp av polymorfism, dvs. beeteendet styrs av typen mottagaren av ett meddelande.
Tips: fundera på hur Sentinel skall fungera när listan är tom.
Skriv också klart create() i klassen LinkFactory (som finns i kodfilen Sentinel.java) så att listor skapas korrekt. Du kan titta på hur create() används i List. (Vi har lagt skapandet av Sentinel-objekt här för att du bara skall behöva skriva kod i en enda fil.)
Kompilera allt med make. Testa din lösning med make test.
Observera att testerna i makefilen är till för att hjälpa dig att hitta fel i din kod och att programmet passerar testerna inte nödvändigtvis betyder att programmet är korrekt eller att du är godkänd.
SPOILER ALERT! SPOILER ALERT! SPOILER ALERT! SPOILER ALERT!
Lösningsförslag
Vi arbetade hårt med att göra uppgiften tydlig så att det inte främst förmågan att läsa instruktioner prövades. Jag har fått kommentarer att den var för enkel, allt den var för svår, att den var omöjlig att förstå. På förmiddagsprovet hade uppgift 2 rubriken "Replace Conditionals with Polymorphism". Den rubriken tog vi bort till eftermiddagen då vi (Tobias och Elias) i efterhand tänkte att den rubriken var vilseledande eventuellt till att tänka bara på polymorfismen och inte allt det andra.
Uppgiften prövar ett antal saker. Man måste förstå subklassning och overriding av metoder och konstruktorer, man måste förstå polymorfism och dynamisk bindning, man måste kunna läsa den utdelade Javakoden. Vill man kunna testa en endast delvist löst uppgift måste man förstå t.ex. att kommentera ut saker i den utdelade koden, eller hur man skriver egna små tester, etc.
Vi valde att ge ett tips om att rita istället för att ge ut något i stil med denna bild:
Lista av längden 0: List --> Sentinel Lista av längden två: List --> Link --> Link --> Sentinel
Utifrån några vanliga fel kunde vi ha varit ännu mer tydliga och ritat ut denna:
Lista av längden 0: List --> Sentinel | | V null lista av längden två: List --> Link --> Link --> Sentinel | | | | | | V V V elem elem null
För att sentinel-objektet skall komma sist i listan måste LinkFactory.create() returnera en Sentinel. Efftersom returtypen är Link måste Sentinel ärva av Link. Orden subklass och arv förekommer inte i texten, utan det var meningen att detta skulle listas ut.
Så här skulle alltså create() se ut:
class LinkFactory {
public static <T> Link<T> create() {
return new Sentinel<T>();
}
}
Det går även bra att ändra returtypen till Sentinel<T>.
Lösning på prov 1
public class Sentinel<T> extends Link<T> {
public Sentinel() {
super(null, null);
}
boolean contains(T element) {
return false;
}
int size() {
return 0;
}
Link<T> append(T element) {
return new Link<T>(element, this);
}
}
Lösning på prov 2
public class Sentinel<T> extends Link<T> {
public Sentinel() {
super(null, null);
}
int occurrences(T element) {
return 0;
}
int size() {
return 0;
}
Link<T> remove(T element) {
return this;
}
}
I något enstaka fall har jag sett koden för append() i remove() vilket skulle kunna göra mig misstänksam, iallafall om lösningen i övrigt är "perfekt".
Vanliga fel
Sentinel äver inte av Link
Flera har inte etablerat en arvsrelation mellan Sentinel och Link. Detta medför att create() inte kompilerar och att programmet inte går att testa, oavsett om koden i Sentinel är rätt i övrigt.
I något enstaka fall har en arvsrelation mellan Sentinel<T> och Link etablerats. Likaså mellan Sentinel och Link<T>. Det senare kompilerar inte. Det förra etablerar inte rätt subtypsrelation mellan Sentinel<T> och Link<T>!
Stafvel ==> ingen overriding
Ett stort antal har stavat fel till occurrences() på eftermiddagsprovet. Detta har lett till att programmet kraschar med ett NullPointerException i Link.occurrences(). Första provet jag rättade som gjorde detta fel gav mig några minuters huvudbry innan jag såg varför lösningen inte fungerade.
Detta hade kunnat avhjälpas med hjälp av skriva @Override-annoteringar på alla metoder som skulle göra override. En kompilator/IDE som prövar detta skulle då ha fångat stavfelet.
Missa att anropa superklassens konstruktor
Detta verkar ha varit ett väldigt vanligt fall, men det är svårt att avgöra hur vanligt eftersom jag bara har en version av koden. Klassen Link har en konstruktor som tar två argument, och om man subklassar Link i Sentinel måste den senares konstruktor anropa superklassens konstruktor (som vanligt). Detta är alltid fallet i Java, men om det finns en konstruktor som inte tar några argument, så stoppas det anropet in automagiskt.
Det vill säga, om Link hade sett ut så här:
public class Link<T> {
public Link() {
this(null, null);
}
public Link(T element, Link next) {
this.value = element;
this.next = next;
}
...
}
Hade man inte behövt ha en konstruktor alls i Sentinel! Det måste man nu, och här är min lösning:
public class Sentinel<T> extends Link<T> {
public Sentinel() {
super(null, null);
}
...
}
Nu till det som verkar ha varit kodprovets största miss, nämligen Javakompilatorns felmeddelande när man glömmer superanropet:
Sentinel.java:7: error: constructor Link in class Linkcannot be applied to given types; public class Sentinel extends Link { ^ required: T#1,Link found: no arguments reason: actual and formal argument lists differ in length where T#1,T#2 are type-variables: T#1 extends Object declared in class Sentinel T#2 extends Object declared in class Link Note: List.java uses unchecked or unsafe operations. Note: Recompile with -Xlint:unchecked for details. 1 error
Detta felmeddelande är inte särskilt lättläst så det är inte enkelt att se att det är konstruktorerna som det är fel på... Illa! Om man kompilerar med -Xlint som föreslås ovan får man exakt samma meddelande en gång till. Hjälpsamt! (Alltså inte.)
Med facit i hand borde jag ha skrivit ett tips om detta i kodprovet, men eftersom det här felet inte uppkom när kodprovet provades ut så var det ingenting som jag tänkte på. Det är extremt synd om någon skulle ha kastat in handsken på grund av detta! Om det är någon tröst så tar jag det med mig till framtida kodprov.
Jag har fått ett par kommentarer i den inlämnade koden om just detta problem av typen "Det här problemet kan jag inte lösa utan Google!". Å ena sidan kan jag tycka att man skall kunna göra det, men å andra sidan kanske inte under en kodprovssituation.
En massa tomma fält i Sentinel, trots arv...
Flera har, trots att Sentinel ärver av Link, deklarerat attribut Link next och T element i Sentinel. Dessa har med något undantag antingen inte använts, och/eller alltid innehållit null.
Jag har svårt att förstå varför man skulle skriva sådan kod. Jag skulle kunna tänka mig att man gör detta för att bli av med ovan nämnda svårtolkade kompileringsfel, men det är en vild gissning. Om inte speglar det nog att man hade svårt att förstå uppgiften, eller vad en sentinel var.
Det förekommer också många konstruktorer som tar två argument som skickas vidare till superklassens konstruktor men som alltid är null.
Observera att detta är ingenting som man får fel för på kodprovet, men jag har försökt att anmärka på alla lösningar som gör detta.
Sentinel-objektet försvinner vid append/remove
En lösning av remove() som gör return null; eller en lösning av append() som gör return new Link(element, null) tappar sentinel-objektet ur listan. Detta leder inte omedelbart till en krasch, men om man t.ex. gör remove() följt av size() så får man ett NullPointerException när man kommer till slutet av listan.
Hur jag har rättat Java-delen
Två saker har påverkat rättningen av Java-delen av kodprovet denna gång:
- Uppgiften var uppenbarligen svår, framförallt med avseende på super-anropet, där Javakompilatorn är närmast fientlig.
- En av ThinLink-servrarna gick ned under eftermiddagens kodprov och skapade strul för många. Det är inte optimala förhållanden för att skriva ett kodprov.
Jag har tagit båda dessa saker under beaktande i min rättning, och det leder till en stor mängd rester (50% blev godkända, 25% rest). Jag föredrar att inte se det som att "jag har varit snäll", utan snarare som kodprovet består av tre delar: Tobias gör provet, studenter skriver provet, Tobias rättar provet; och alla dessa tre delar måste funka tillsammans. Syftet med kodprovet, som jag har sagt 1000 gånger under kursen, är att se till att någon som normalt bara skulle sitta brevid och se på när någon annan kodar faktiskt sätter sig ned och kodar själv. All betygssättning sker ju via redovisningar av mål.
Som jag skrev ovan är det många saker som uppgiften prövar: subklassning, subtypning, overriding, konstruktorer, att kunna läsa den utdelade Java-koden så att man t.ex. förstår hur occurrences() eller size() skall implementeras. Om koden visar att man har förstått lejonparten av detta har man fått rest, oavsett om koden kompilerar eller ej.
En student har gjort en lösning med hjälp av interface som visar på förståelse för det grundläggande problemet. Trots att lösningen inte är komplett har denna student också fått rest, med villkor att lösningen vid resttillfället fortsätter med interfacelösningen.
När jag rättar har jag ingen aning om vems prov jag rättar (det får ni ta mitt ord på, eller inte). Det gör att jag varken kan eller vill tolka någon lösning utifrån min kännedom om den som skrivit den (jag vet ju att X kan detta..., etc.). Det betyder att vissa rester mest blir en formsak, men det skall vara lika för alla. Alla ni som har mailat mig rätt lösning senare under dagen -- tack! Tyvärr måste ni fortfarande komma och göra resten eller skriva om kodprovet, beroende på hur det gick.
Med något enstaka undantag har endast körande felfria lösningar blivit godkända. Det betyder att följande lösning har givit rest, trots att den är super(null,null) från att bli godkänd. Vid resten så vill jag veta varför man måste anropa superklassens konstruktor och vad tanken är bakom detta, etc.
public class Sentinel<T> extends Link<T> {
public Sentinel() {
///
}
int occurrences(T element) {
return 0;
}
int size() {
return 0;
}
Link<T> remove(T element) {
return this;
}
}
På samma sätt har följande lösning, som inte passerar testerna lett till rest. I nedanstående fall vill jag bl.a. veta vid resttillfället att man förstår varför detta inte fungerar, och hur programmet beter sig under körning.
public class Sentinel<T> extends Link<T> {
public Sentinel() {
super(null, null);
}
int occurences(T element) { // Stavfel
return 0;
}
int size() {
return 0;
}
Link<T> remove(T element) {
return this;
}
}
Om du vill diskutera detta finns det en bra tråd i Piazza här, eller skicka mig ett mail eller fånga mig AFK på Pollax.