Objectgeoriënteerd programmeren

An Braeken - an.braeken@vub.be

Laurent Segers - laurent.segers@vub.be

Een speciale dank gaat naar Benjamin Lapauw, Jonas Verbeke, Mathias Knop en Dugagjin Lashi voor het nalezen van de oefeningen en de korte samenvattingen op deze website. Tevens worden ze ook bedankt voor het meehelpen tijdens het lesgeven.

Tutorial Visual Studio

Hieronder vinden jullie de oefeningen voor de WPO's objectgeoriënteerd programmeren. De oefeningen zijn onderverdeeld in 3 categorieën: A, E en X. A zijn de algemene basisoefeningen en zijn het minimumniveau. De oefeningen van de E reeks zijn het niveau dat verwacht wordt. De X-oefeningen (Xtreme) zijn voor de durvers.

Tijdens de oenfeningensessies wordt er van je verwacht dat je actief meewerkt. Is dit niet het geval en verstoor je de groep of kom je te laat binnen, dan kan de assistent je de toegang tot die sessies weigeren.

Oefening baart kunst! Iedereen kan leren programmeren, maar je leert het alleen door veel te oefenen!

Weerstation

Om je de kans te geven je programmeervaardigheden te demonstreren in een concrete toepassing, programmeer je als zelfstandige opdracht de software voor een weerstation. Dit doe je op basis van bestaande open source hardware die op de VUB werd ontwikkeld, WeatherStation v3.0. Je kan zo'n weerstation komen ontlenen in het Elektronicalab bij de aanvang van het tweede semester.

Het volledige hardwaredesign vind je op CircuitMaker.

De software (firmware en bootloader) staan op Github, en gedocumenteerd op de OpenObservatory website.

In de opdrachtbeschijving vind je een Nederlandstalige samenvatting van deze resources, en de doelstellingen van het project.

Het weerstation staat op 30% van de eindscore van dit vak. De andere punten staan op het WPO-examen (40%) en theorie-examen(30%).

Les 1

Theorie: herhaling 1ste bachelor

Opgaven WPO1

In dit eerste WPO herhalen we de leerstof Informatica van het eerste jaar. Voor meer oefeningen wordt verwezen naar de pagina van het eerste jaar. Door deze oefeningen op te lossen bereid je jezelf goed voor op de oefeningen van object georiëteerd programmeren. Hierdoor krijg je de syntax terug goed onder de knie.

Les 2

Theorie: schrijven van een eigen klasse

Opgaven WPO2

Klasse schrijven

In dit WPO wordt de basis gelegd van object georiënteerd programmeren. De eerste stappen richting het schrijven van klassen en het gebruik van hun instanties zal hier ook aan bod komen. In de OOP wereld worden de termen "klasse" en "object" vaak door elkaar gebruikt. Vaak worden deze foutief als synoniemen beschouwd. Het grote verschil tussen beide termen is dat een klasse de code (blauwdruk) zelf voorstelt terwijl het object de instantie van de klasse is. In wat volgt zullen we werken met de klasse "dataContainer" waarin we een getal in zullen bijhouden. In de meest eenvoudige vorm wordt de klasse geschreven als de klassedefinitie tesamen met interne variabele (veld). Dit wordt in onderstaand codefragment weergegeven.

// start the class by writing this first line-height
// each class starts with public (visiable in complete programm) 
// or private (only visible in sections of the programm)
public class dataContainer
{
	// declare fields of the class
	int data;
}

De klasse dataContainer bevat nu 1 veld genaamd "data" van het type integer. In de OOP wereld geldt echter als regel dat men interne velden het liefst van de "buitenwereld" afschermt. T.t.z. dat men niet wilt dat een veld door een andere code dan het object zelf aangepast kan worden. Om dit te doen zal men de velden dan ook als "private" declareren. Bovenstaand codefragment wordt dus:

// start the class by writing this first line-height
// each class starts with public (visiable in complete programm) 
// or private (only visible in sections of the programm)
public class dataContainer
{
	// declare fields of the class (private)
	private int data;
}

Getters en setters, properties

Het programma kan nu echter niet meer aan dit veld aan (zowel lezen en schrijven). Om dit te verhelpen moet de klassen een manier toelaten om dit toch te kunnen doen. Dit kan gebeuren a.d.h.v. getters/setters en/of properties. Door gebruik te maken van deze techniek kan men er zich van verzekeren dat de interne velden altijd een juiste inhoud bevatten. Veronderstel dat men wilt dat het veld beperkt blijft tussen -100 en 100, dan kan men dit realiseren door een eenvoudige if-conditie. Dit wordt in onderstaande codefragment geïllustreerd.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// getter 
	public int getData()
	{
		return data;
	}
	// setters
	public void setData(int value)
	{
		if ((value>=-100)&&(value<=100)) data = value;
	}
	// equivalent for getters and setters: properties
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			if ((value>=-100)&&(value<=100)) data = value;
		}
	}
}

Het gebruik van getters en setters naast properties wordt zelden gedaan aangezien deze beide hetzelfde werk verrichten. Meestal wordt om het wijzigen van velden enkel naar de properties gegrepen. Bij de properties geldt dat ze pas getten en setten wanneer de get en set directieven geprogrammeerd zijn. Indien de get of set ontbreekt, zal maar 1 van de 2 delen functioneel zijn. Merk op dat de waarde van de setter zich altijd in "value" bevindt. In de regel geldt dat als men een property en/of getters en setters publiekelijk toegankelijk wil maken naar de rest van het programma dat men ze als "public" moet declareren. In wat volgt zullen we dezelfde klasse gebruiken, maar enkel met de bovenvermelde property.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// property for data (get and set)
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			if ((value>=-100)&&(value<=100)) data = value;
		}
	}
}

Object aanmaken en gebruiken

De klasse zoals hierboven kan al geïnstantieerd worden. Dit instantiëren gebeurt doorgaans buiten de klasse zelf in de rest van het programma.

// create object datacontainer1 of class dataContainer
dataContainer datacontainer1 = new dataContainer();

Het object datacontainer1 is hierbij de instantie van de klasse dataContainer. Vereenvoudigd kan men zeggen dat deze regel overeenkomt met: definieer een variabele (datacontainer1 ) van het type dataContainer (klasse) en maak hem aan (new). Eenmaal het object aangemaakt is, kan men de properties en methoden (enkel de publieke) ervan aanspreken. Dit kan men als volgt doen:

dataContainer datacontainer1 = new dataContainer();
// put value 5 in the property
datacontainer1.Data = 5;  // is allowed since we have a setter
// read the value out of the property
int val = datacontainer1.Data; // is allowed since we have a getter

Indien men de waarde 512 (i.p.v. 5) in de property zou willen introduceren, levert dit niet het gewenste resultaat op. Waarom?

Methoden

Klassen bevatten naast getters, setters, velden en properties ook methoden die toelaten om de toestand van het object te veranderen. Methoden werken net zoals functies en procedures. Het grote verschil is echter dat deze vaak onmiddelijk effect hebben op het huidige object. In wat volgt wordt een methode geschreven die toelaat om het veld te vermenigvuldigen met een bepaalde waarde die als argument opgegeven wordt. Uiteraard wordt het resultaat opnieuw nagekeken op de opgelegde grenzen. Deze methode wordt uiteraard ook als public gedeclareerd zodat men er van buitenaf aan kan.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// property for data (get and set)
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			if ((value>=-100)&&(value<=100)) data = value;
		}
	}
	// multiply value with another value in a method + do check if >=-100 and <=100
	public void multiply(int mul)
	{
		data = data * mul;
		if (data<-100) data = -100;
		if (data>100) data = 100; 
	}
}

Deze methode kan gebruikt worden zoals weergegeven in onderstaand codefragment.

dataContainer datacontainer1 = new dataContainer();
// put value 5 in the property
datacontainer1.Data = 5; 
// read the value out of the property
datacontainer1.multiply(5);
int val = datacontainer1.Data; // returns 25

Net zoals functies kunnen methoden een willekeurig aantal argumenten opnemen en een return statement hebben.

Bovenstaande klasse maakt gebruik van 2 gelijkaardige stukken code: het vergelijken van de waarde op de opgelegde grenzen. Dit laatst kan gecentraliseerd worden in een aparte (private) methode.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// property for data (get and set)
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			data = value;
			checkBorders();
		}
	}
	// multiply value with another value in a method + do check if >=-100 and <=100
	public void multiply(int mul)
	{
		data = data * mul;
		checkBorders();
	}
	// private method to check validity of the value
	private void checkBorders()
	{
		if (data<-100) data = -100;
		if (data>100) data = 100; 
	}
}

Dit heeft als voordeel dat men een stukje code slechts eenmaal hoeft te schrijven en men er zeker van is dat deze werkt. Bovendien hoeven we deze nieuwe methode gewoon op te roepen elders in de klasse zelf. Deze methode is echter niet van buitenaf het object beschikbaar.

Constructor

Elke klasse dat geschreven en tot object geïnstantieerd wordt, maakt gebruik van een constructor. Dit geldt ook voor bovenstaande voorbeelden. Een constructor hoeft niet gespecifieerd te worden in de klasse indien er bij het aanmaken van het object geen speciale acties ondernomen hoeven te worden. Indien men wel zelf een constructor schrijft, dan moet deze aan een aantal regels voldoen:

  • de constructor heeft dezelfde naam als de naam van de klasse,
  • een constructor heeft minstens 0 argumenten, maar kan ook een willekeurig aantal argumenten hebben,
  • een constructor retourneert niets (geen return), bijgevolgd wordt een constructor nooit voorafgegaan door void, int, of iets dergelijks.

De constructor van de bovenvermelde klasse zou echter een getal kunnen opnemen zodat het bijpassende veld onmiddelijk ingevuld wordt.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// constructor (also public)
	public dataContainer(int val)
	{
		data = val;
		checkBorders();
	}
	// property for data (get and set)
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			data = value;
			checkBorders();
		}
	}
	// multiply value with another value in a method + do check if >=-100 and <=100
	public void multiply(int mul)
	{
		data = data * mul;
		checkBorders();
	}
	// private method to check validity of the value
	private void checkBorders()
	{
		if (data<-100) data = -100;
		if (data>100) data = 100; 
	}
}

De constructor die nu gebruikt wordt vult het getal onmiddelijk in voert ook de nodige check uit. Merk op dat zolang er geen constructor geschreven wordt in de klasse, men altijd terugvalt op de standaard constructor. Eenmaal men er zelf schrijft moet men een geschreven constructor gebruiken. De standaard constructor komt in dat geval te vervallen. Het aanmaken van het object verschilt hierdoor t.o.v. vroeger.

dataContainer datacontainer1 = new dataContainer(5);
// read the value out of the property
datacontainer1.multiply(5);
int val = datacontainer1.Data; // returns 25

Het is ook mogelijk om 2 of meer constructoren in een klasse te schrijven. In dat geval kan men tijdens het aanmaken van een object de meest geschikte constructor gebruiken.

public class dataContainer
{
	// declare fields of the class (private)
	private int data;
	// constructor (also public)
	public dataContainer()
	{
		// do something here
	}
	public dataContainer(int val)
	{
		data = val;
		checkBorders();
	}
	// property for data (get and set)
	public int Data
	{
		get{return data;} // if returning is allowed, programm "get"
		set // if setting is allowed, programm the "set"
		{
			data = value;
			checkBorders();
		}
	}
	// multiply value with another value in a method + do check if >=-100 and <=100
	public void multiply(int mul)
	{
		data = data * mul;
		checkBorders();
	}
	// private method to check validity of the value
	private void checkBorders()
	{
		if (data<-100) data = -100;
		if (data>100) data = 100; 
	}
}
// both constructors are now valid
dataContainer datacontainer2 = new dataContainer(5);
dataContainer datacontainer1 = new dataContainer();
// read the value out of the property
datacontainer1.multiply(5);
int val = datacontainer1.Data; // returns 25

Reference to object

Een neveneffect van objecten te gebruiken is dat deze via een references gebruikt worden. Een reference kan bekeken worden als een verwijzing naar het object. Dit betekent dus dat we nooit een object rechtstreeks benaderen, maar via de verwijzing. Dit effect is het meest zichtbaar wanneer men een object doorgeeft aan een andere variabele.

// both constructors are now valid
dataContainer datacontainer1 = new dataContainer(5);
dataContainer datacontainer2;
// copy the reference into datacontainer2
datacontainer2 = datacontainer1;
// read the value out of the property
datacontainer2.multiply(5);
// returns 25, even if datacontainer1 was no used for the multiplication
int val = datacontainer1.Data; 

Zelfs al gebruiken we het object niet via zijn originele variabele, kan het gebeuren dat de toestand van het object gewijzigd kan worden. Hiermee dient ten alle tijde rekening gehouden mee te worden. Wenst men een kopie te maken van het origineel object, dan is het best om een nieuw object aan te maken waarin alle velden worden gekopieerd. Hiervoor kan men best een methode "clone" aanmaken in de klasse zelf.

Static methoden

Naast de gewone methoden bestaan er ook de "static" methoden. Dit type van methode verschilt in programmatie en gebruik t.o.v. de gewone methoden. Static methoden behoren wel tot de klasse, maar maken geen deel uit van de objecten die uit deze klasse voortvloeien. Het is dus niet mogelijk om bij een object dergelijke methodes aan te roepen. Juist doordat deze methoden geen deel uitmaken van objecten, kunnen deze methoden ook geen invloed hebben hebben op de toestand van het huidige object. Dit type van methoden wordt voornamelijk gebruikt wanneer men functies schrijft die geen directe link kunnen vertonen met een bepaald object. Denk hierbij aan de klasse "Math" waarin een aantal methoden geïmplementeerd zijn zoals sinus, cosinus, tangens, enz. Static methoden worden altijd voorafgegaan door het sleutelwoord "static". Hieronder wordt een voorbeeld gegeven van een static methode en het gebruik ervan.

public class dataContainer
{
	// other code 
	public static dataContainer clone(dataContainer obj)
	{
		dataContainer ret = new dataContainer();
		ret.data = obj.data;
		return ret;
	}
}

een mooi voorbeeld van een static methode is de methode clone. Deze methode moet nu een argument openemen van het type "dataContainer" omdat deze methode niet op het object zelf werkt. De methode retourneert een kopie van het object. Het aanroepen van deze methode wordt hieronder geïllustreerd.

dataContainer datacontainer1 = new dataContainer(5);
dataContainer datacontainer2;
// call the static method from the class itself!!
datacontainer2 = dataContainer.clone(datacontainer1);

Les 3

Theorie: overerving en abstracte klassen

Opgaven WPO3 (overerving)

Opgaven WPO3 (abstracte klassen)

Bijlagen weerstation

Overerving

In het voorgaande WPO hebben we gezien hoe we een klasse konden schrijven en op welke manier men deze tot een object kon instantiëren. In de oefeningen die daarop volgden werd er telkens gebruik gemaakt van 1 enkel type object. In grotere programma's kan het echter voorkomen dat men tijdens het schrijven van verschillende klassen opmerkt dat een aantal klassen gemeenschappelijke of gelijkwaardige code bevatten. Als deze klassen een gelijkwaardige functionaliteit implementeren, kan het nuttig zijn om deze gemeenschappelijke functionaliteiten onder te brengen in een gemeenschappelijke klasse en deze klasse over te erven door andere klassen die elk een specifiek deel implementeren. Als voorbeeld zullen we de klassen Bubble en Ball bespreken.

Deze 2 klassen hebben volgende eigenschappen:

  • x- en y-positie en,
  • een diameter d.

Deze eigenschappen zijn gemeenschappelijk voor beide klassen en kunnen ondergebracht worden in een gemeenschappelijke klasse: Sphere. De klasse Sphere kan er als volgt uitzien:

public class Sphere
{
	private double x;
	private double y;
	private double diameter;
	//------------------------------------------------------------
	public Sphere(double x, double y, double diameter)
	{
		this.x = x;
		this.y = y;
		this.diameter = diameter;		
	}
	//------------------------------------------------------------
	public double X
	{
		get {return x;}
		set {x = value;}
	}
	//------------------------------------------------------------
	public double Y
	{
		get {return y;}
		set {y = value;}
	}
	//------------------------------------------------------------
	public double Diameter
	{
		get {return diameter;}
		set {diameter = value;}
	}
}

Als bijzondere eigenschappen hebben de klassen Ball en Bubble respectievelijk: een gevulde schijf en en lege cirkel. Deze specialisaties worden via overerving geïmplementeerd.

public class Bubble: Sphere
{
	private Color contourcolor;
	//------------------------------------------------------------
	public Bubble(double x, double y, double diameter,Color clr):base(x,y,diameter)
	{
		this.contourcolor = clr;
	}
	//------------------------------------------------------------
	public Color ContourColor
	{
		get {return contourcolor;}
		set {contourcolor = value;}
	}
}
public class Ball: Sphere
{
	private Color fillcolor;
	//------------------------------------------------------------
	public Ball(double x, double y, double diameter,Color clr):base(x,y,diameter)
	{
		this.fillcolor = clr;
	}
	//------------------------------------------------------------
	public Color FillColor
	{
		get {return fillcolor;}
		set {fillcolor = value;}
	}
}

De overerving (overnemen van de functionaliteiten van Sphere) vindt plaats door de nieuwe klasse te schrijven, en de laten volgen door ":Sphere". Vanaf dat moment kan de kindklasse (Ball, Bubble) de eigenschappen van de superklasse (Sphere) gebruiken. Op dit moment vullen de kindklassen de superklasse met een kleur. In de verdere uitleg zullen beide kindklassen weldegelijk een specialiteit invullen.

Constructor

Wat opvalt is dat de constructor een speciale syntax heeft. Het eerste deel (voor de ":") is de constructor van de kindklasse zelf. Het tweede deel is chter de aanroep van de constructor van de superklasse. Merk op dat de argumenten onmiddelijk ingevuld kunnen worden. Als argumenten kan men enkel de argumenten meergeven van de constructor van de kindklasse, of constanten. Om de constructor van de superklasse aan te roepen volstaat het om de "base" te gebruiken met de gepaste argumenten. Indien de superklasse over meerdere constructoren beschikt, kan de meest geschikte constructor gebruikt worden.

Private, protected en public

Tot nu toe zijn de methoden en velden enkel als private of als public gedeclareerd. Naast public en private bestaat er ook "protected". De verschillende vormen van zichtbaaarheid worden hieronder opgesomd:

  • Private: enkel zichtbaar binnen de eigen klasse. Niet zichtbaar buiten de klasse en ook niet zichtbaar binnen kindklassen.
  • Protected: enkel zichtbaar binnen de eigen klasse en binnen de kindklassen.
  • Public: zichtbaar voor iedereen.

Public betekent dus zichtbaar voor iedereen. Private bevindt zich aan het andere eind van het spectrum: enkel zichtbaar voor de klasse zelf. Protected is een mildere vorm dan private en bevindt zich tussenbeide. Dit laatste kan handig zijn in beide kindklassen. Door gebruik te maken van enkel de public en private zijn beide kindklassen verplicht van de properties van de superklasse aan te spreken wat betreft positie en diameter. Dit kan vermeden worden door de velden in de superklasse als protected te declareren.

public class Sphere
{
	protected double x;
	protected double y;
	protected double diameter;
	//------------------------------------------------------------
	//.... other stuff here
}

In de kindklasse kunnen de velden als volgt geraadpleegd worden:

public class Ball: Sphere
{
	private Color fillcolor;
	// generic method
	public void doSomething()
	{
		// x can be accessed now.
		double a = x;
	}
}

This en base

Soms is het onduidelijk welk veld/methode tot de kindklasse of van de superklasse behoort. Om die reden wordt er vaak naar de sleutelwoorden "this" en "base" gegrepen. Bij gebruik van this bedoelt men dat men de velden/methoden van de eigen klasse wilt aanspreken. Bij gebruik van base wilt men de velden/methoden van de superklasse aanspreken. Het gebruik hiervan wordt hieronder weergegeven.

public class Ball: Sphere
{
	private Color fillcolor;
	// generic method
	public void doSomething()
	{
		// x can be accessed now.
		double a = base.x;
		Color clr = this.fillcolor;
	}
}

Virtual en override

Het kan voorkomen dat men bij het schrijven van een kindklasse een bestaande methode wil overschrijven. Dit is bv het geval met de methode "ToString()". Deze methode wordt van de superklasse "object" overgeërfd en bevat een generische invulling. Om hier een beter invulling aan te geven volstaat het deze methode over te schrijven (override) door dezelfde methode in de eigen klasse. Dit wordt hieronder getoond.

public class Ball: Sphere
{
	public override void ToString()
	{
		return "This is a ball";
	}
}

Dit concept kan verder doorgetrokken worden door zelf methoden te schrijven die door de kindklassen overschreven kunnen worden. Deze methode moet echter wel als "virtual" gedeclareerd worden. Dit principe worddt hieronder gedemonstreerd.

public class Sphere
{
	// declare as virtual 
	public virtual void Draw(Canvas cvs)
	{		
		Ellipse el = new Ellipse();		
		el.Stroke = new SolidColorBrush(Colors.Black);
		el.StrokeThickness = 5;		
		el.Width = this.diameter;
		el.Height = this.diameter;		
		Canvas.SetTop(el, this.y);
		Canvas.SetLeft(el, this.x);
	}
}
public class Ball: Sphere
{
	// override old method from the superclass 
	public override void Draw(Canvas cvs)
	{
		Ellipse el = new Ellipse();		
		el.Fill = new SolidColorBrush(this.fillcolor);		
		el.Width = base.diameter;
		el.Height = base.diameter;		
		Canvas.SetTop(el, base.y);
		Canvas.SetLeft(el, base.x);
	}
}

Merk hierbij op dat de methode van de superklasse en de kindklasse identiek hetzelfde moet zijn (return en argumenten ook) om te kunnen overriden. Zijn ze niet gelijk, dan is een override niet nodig. Merk op dat overriden niet altijd gewenst is. Denk bij het schrijven van de superklasse hierbij altijd goed na of een override noodzakelijk kan zijn. Deze methoden wordedn doorgaans als public aangegeven.

Instantiëren tot object

Hierboven is aangehaald hoe men een klasee kan overerven, methoden kan overriden en de constructors kan doorlinken. In wat volgt als het gebruik ervan volgen om objecten aan te maken. Het aanmaken van dergelijk objecten vindt op dezelfde wijze plaats als voor gewone objecten. Hieronder wordt dit verduidelijkt.

Sphere s = new Sphere(0,0,25);
Sphere s2 = new Bubble(0,0,25,Colors.Red);
Bubble b = new Bubble(0,0,25,Colors.Red);
Ball b2 = new Ball(10,10,30,Colors.Blue);

Hierbij valt op dat je een object kan declareren van de superklasse, maar ook kan instantiëren a.d.h.v. een kindklasse. De regel hierbij is dat de instantie steeds minstens even gespecialiseerd is als de klasse zelf. Meer gespecialiseerd mag, minder mag niet. Dus onderstaande kan niet:

// invalid code below!!!
Bubble b = new Sphere(0,0,25);

Abstracte klassen

Overerving zoals gezien in het eerste deel van dit WPO is handig omdat delen van een bestaande klasse overgenomen worden door de kindklassen. Een aantal extra functionaliteiten hoeven nog geprogrammeerd te worden. Het kan soms gebeuren dat men in de superklasse al weet waarvoor de klasse gaat dienen, maar men deze niet volledig kan afwerken. Men kan wel al een aantal functies definiëren maar men kan deze nog niet invullen. Andere functies zijn gemeenschappelijk voor alle kindklassen en kunnen wel al ingevuld worden. Om te voldoen aan zowel ingevulde als niet ingevulde methoden kan men gebruik maken van abstracte klassen.

Abstracte klassen worden grotendeels op dezelfde manier als een gewone klasse geschreven. Twee grote verschillen zijn echter op te merken:

  • de klasse wordt voorafgegaan door het woordje abstract
  • een aantal methoden zijn gedeclareerd maar worden niet ingevuld. Deze zijn bodyless

In onderstaand codefragment wordt het gebruik van abstracte klassen verduidelijkt.

abstract class shapeClass
{
	//-- we define the method without implementation
    abstract public int Area();
}
//---------------------------------------------
class rechthoek:shapeClass
{
	//-- must be filled with a function body
	public override int Area()
	{
		return 4*3;
	}
}
//---------------------------------------------
class cirkel:shapeClass
{
	//-- must be filled with a function body
	public override int Area()
	{
		return 2*Math.PI*3*3;
	}
}

Een abstracte klasse is per definitie een klasse waarin potentieel methoden gedeclareerd zijn waarin geen uitvoerbare code geschreven is. Hierdoor is deze klasse dus onvolledig en moet deze hoe dan ook overgeërfd worden ook al zijn alle functies wel degelijk uitgeschreven. De kindklassen kunnen ook abstract zijn. De enige klassen die geïnstantieerd kunnen worden zijn de gewone klassen die alle methoden uit de abstracte klasse(n) implementeren.

Les 4

Theorie: interfaces en polymorfie

Opgaven WPO4 (interfaces)

Opgaven WPO4 (polymorfie)

Bijlagen opgaven WPO 4

Oplossing snake programma

De concepten van overerving en abstracte klassen zullen tijdens dit WPO verder uitgebouwd worden. Bij de gewone overerving erft men alle eigenschappen en methoden over van de superklasse. Hierbij worden enkel nieuwe elementen bij elke kindklasse toegevoegd. Abstracte klassen laten echter toe om bepaalde methodes op voorhand te definiëren zonder deze in in te vullen. Deze klasse is dan ook niet volledig en kan in tegenstelling tot gewone superklassen niet tot een object geïnstantieerd worden. Men is dus verplicht om deze klasse over te erven en alle abstracte methoden te implementeren in een niet-abstracte klasse. Het verhaal van overerving en abstractie kan verder doorgetrokken worden. In dit WPO zullen we interfaces en polymorfie behandelen.

Interfaces

Interfaces zijn een nog strengere vorm van abstractie t.o.v. de abstracte klassen. Een abstracte klasse kan nog over een aantal ingevulde methoden beschikken. Een interface daarentegen is op dit vlak volledig abstract. Dit wil dus zeggen dat een interface enkel bestaat uit methode-prototypes en prototypes van properties. Een interface bevat dus geen enkel lijntje uitvoerbare code. Hierdoor zijn alle gedeclareerde methoden/properties altijd public. Er kan immers geen code van de interface (die niet bestaat) aan de inwendige delen van een interface aan. Bij het schrijven van een interface is het sleutelwoordje public dus volledig overbodig. Als conventie laten we een interface steeds met "I" (hoofdletter i) voorafgaan. Hieronder wordt een voorbeeld gegeven.

interface IPoint
{
	void setPoint(double x, double y);
	double X {get; set;}
	double Y {get; set;}	
}

Het implementeren van de interface wordt hieronder weergegeven.

public class point:IPoint
{
	private double x_;
	private double y_;
	//---
	public point(double x, double y)
	{
		setPoint(x,y);
	}
	// implementation mandatory
	void setPoint(double x, double y)
	{
		this.x_=x;
		this.y_=y;
	}
	// implementation mandatory
	double X 
	{
		get {return x_;}
		set {this.x_ = value;}
	}
	double Y
	{
		get {return y_;}
		set {this.y_ = value;}
	}	
}

Een voorbeeld op interfaces is de IList. Van deze interface wordt de klasse List geïmplementeerd. Interfaces kunnen gezien worden als een contract tussen verschillende programmadelen waarbij verschillende programmeurs weten welke methoden en/of properties door een bepaalde klasse geïmplenteerd zullen worden.

Meervoudige overerving

Tijdens de voorgaande WPO's hebben we gezien dat een klasse van maximaal 1 andere (abstracte) klasse kan overerven. Een klasse kan echter van 1 of meerdere interfaces overerven. Ook is het mogelijk dat een klasse van zowel een interface als een (abstracte) klasse kan overerven. In het geval van meervoudige overerving moeten alle niet ingevulde methoden en/of properties ingevuld worden door de kindklasse. Dit wordt hieronder getoond. Meervoudige overerving wordt gescheiden door een komma.

public class point:apoint,IPoint
{
	// write code for the class here 
	
	// implement abstract and interface methods here
}

Polymorfie

Een van de krachtigste elementen in objectgeoriënteerd programmeren is het gebruik van polymorfie. Er wordt ook gezegd dat objectgeoriënteerd programmeren pas echt tot zijn recht komt indien men aan polymorfie doet.

In het Grieks betekent polymorfie: veelvormigheid. Hiermee wordt in de informatica bedoeld dat men veel klassen kan hebben, die dezelfde methodes implementeren (wat betreft naam en argumenten) maar met een verschillende code.

Men kan aan polymorfie doen door een superklasse te definiëren en die te laten overerven door een aantal kindklassen. Men declareert alle kindklassen als het type van de superklasse, maar instantieert ze als een van de kindklassen. In dat geval worden alle objecten van het zelfde type beschouwd. Een voorbeeld wordt hieronder weergegeven. Hierbij hernemen we het voorbeeld van de shapes.

abstract class shapeClass
{
	//-- we define the method without implementation
    abstract public double Area();
}
//---------------------------------------------
class rechthoek:shapeClass
{
	//-- must be filled with a function body
	public override int Area()
	{
		return Height*Width;
	}
	//-----------------------------------------
	public double Height
	{
		get; set;
	}
	//-----------------------------------------
	public double Width
	{
		get; set;
	}
}
//---------------------------------------------
class cirkel:shapeClass
{
	//-- must be filled with a function body
	public override int Area()
	{
		return 2*Math.PI*Diameter*Diameter;
	}
	public double Diameter
	{
		get; set;
	}
}

Declareren als de gemeenschappelijke superklasse is altijd mogelijk, ook als de superklasse abstract of een interface is. Enkel het instantiëren moet volgens een volledige klasse verlopen.

// declare 2 object as shapeClass, but instantiate from one of the subclasses.
shapeClass rect = new rechthoek();
shapeClass circle = new cirkel();
// we can nog take the method Area();
double a_rect = rect.Area();
double a_circ = circle.Area();

In bovenstaande code is het dus mogelijk om telkens de methode Area op te roepen. Dit is mogelijk omdat de superklasse over deze methode beschikt. Het is dus een gekende methode voor shapeClass en alle kindklassen. Doordat alle klassen zich nu als shapeClass laten voordoen, verbergen deze als het ware de extra informatie die beschikbaar is in de kindklassen. In bovenstaande code is het aanroepen van de properties Height, Width en Diameter niet mogelijk. Dit is zo omdat de objecten volgens de superklasse gedeclareerd zijn waar deze properties niet bestaan. Bovenstaande constructie biedt als voordeel dat men vanaf nu alle objecten die bv. een tekenmethode overerven van een gemeesnchappelijke superklasse in eenzelfde lijst bijgehouden kunnen worden.

Het sleutelwoord "is"

Door het declareren van alle objecten als de superklasse verliest men de informatie van de specifieke implementaties (kindklassen). Hierdoor is het niet meer mogelijk om die informatie rechtreeks aan te spreken. Men kan dit echter wel door de objecten te casten naar een specifieke klasse. Echter loopt men hierbij het risico dat de objecten voor een verkeerde type-cast kunnen zorgen. Dit probleem kan verholpen worden door de objecten te testen op type vooraleer ze te casten. Dit testen kan door gebruik te maken van het sleutelwoordje "is". Dit principe wordt hieronder weergegeven.

// make a list of shapes
List<shapeClass> l = new List<shapeClass>();
l.Add(new rechthoek());
l.Add(new cirkel());

// set height, width or diameter depending on the class 
for (int i=0;inew cirkel();l.Count;i++)
{
	if (l[i] is rechthoek) // first check type with "is"
	{
		// typecast
		rechthoek r = (rechthoek)l[i];
		// now we can access the features of rechthoek
		r.Width = 10;	
		r.Height = 15;
	}
	else if (l[i] is cirkel)
	{
		// typecast
		cirkel c = (cirkel)l[i];
		// now we can access the features of cirkel
		c.Diameter = 5;
	}
}
// calculate the area for each class
for (int i=0;inew cirkel();l.Count;i++)
{
	double a = l[i].Area();
}

Les 5

Theorie: exceptions

Opgaven WPO5

Bijlagen opgaven WPO 5

Nadat de concepten van overerving behandeld zijn, kan er aandacht besteed worden aan het robust maken van een programma. Tijdens deze oefeningensessie zullen we de basis zien van foutafhandeling. Exceptions maken het mogelijk om (naast de klassieke if-structuren) een programma te beveiligen tegen onverwachte fouten. Fouten in een programma kunnen verschillende oorzaken hebben:

  • een fout intern in het programma: de programmatuur is verkeerdelijk of onvoldoende getest,
  • een fout t.g.v. menselijke input: bv. indien een persoon een tekst i.p.v. een getal ingeeft,
  • een onverwachte fout als gevolg van onderdelen die het programma nodig heeft om te functioneren: bv. het uitlezen van een seriële poort die plots stopt met functioneren.

over het algemeen geldt dat de meeste fouten door simpele programmatorische condities opgelost kunnen worden. Vaak kan de programmeur de inputs van de gebruiker relatief gemakkelijk aftoetsen met een eenvoudige if-structuur. De fouten als gevolg van een ingewikkelde programmatuur kunnen soms opgelost worden, soms niet. De fouten t.g.v. onverwachte gebeurtenissen kunnen echter niet vermeden worden. Deze laatsten kunnen opgelost worden door het deel van het programma dat mogelijke problemen kan opleveren in een zandbak uit te voeren. Hierbij wordt de code uitgeprobeerd. Is er geen probleem, dan wordt het programma verder volgens normaal stramien uitgevoerd. Duikt er een probleem op, dan wordt het programma hiervan op de hoogte gebracht zodat de gepaste acties ondernomen kunnen worden. Dit kan gerealiseerd worden d.m.v. een try-catch blok. Een try-catch blok ziet er typpisch als volgt uit:

try
{
	// try code here, 
	// code put here will be ran until an error occurs
	// if no errors occurs, it will continue until the end (curly braces)
}
catch(Exception ex)
{
	// print at least the message of the exception
	MessageBox.Show(ex.Message);
}
finally
{
	// code that always need to be ran, even if the exception did not happen
}

Een try-catch blok wordt steeds opgebouwd door de delen try en catch te programmeren. De finally blok is echter optioneel. Het try-gedeelte wordt altijd uitgevoerd totdat er een fout optreedt of totdat alle code is uitgevoerd. In het geval dat een fout zich heeft voorgedaan, wordt het catch-gedeelte uitgevoerd. In dat geval wordt er als argument de aard van de fout meegegeven (Exception ex). Indien er geen fout plaatsvond, word het catch-gedeelte niet uitgevoerd. Indien men in beide gevallen wenst dat er bepaalde code uitgevoerd wordt, kan men een finally clausule toevoegen. Deze code wordt altijd uitegevoerd. Dit kan handig zijn indien men bepaalde resources wilt vrijmaken die tijdens het try-gedeelte zijn aangemaakt of gereserveerd.

net zoals andere structuren (if, for, while, enz.) is het mogelijk om verschillende try-catch blokken in elkaar te nesten. In dit geval wordt er meerbepaald enkel in de try-blokken genest.

try
{
	try
	{
		// try code here, 
		// code put here will be ran until an error occurs
		// if no errors occurs, it will continue until the end (curly braces)
	}
	catch(Exception ex1)
	{
		// print at least the message of the exception (inner exception)
		MessageBox.Show(ex1.Message);
	}
}
catch(Exception ex2)
{
	// print at least the message of the exception (outer exception)
	MessageBox.Show(ex2.Message);
}

Hierboven zijn exceptions gebruitk van het type Exception. het is ook mogelijk om exceptions van andere typen te gebruiken, zolang ze binnen de try-catch blok potentieel afgevuurd kunnen worden. Een aantal bekende zijn de DivideByZeroException, IOException en de NullReferenceException. Alle exception die niet het type "Exception" hebben, worden (onrechtstreeks) afgeleid van Exception zelf. Ze erven als het ware over van hun moederklasse Exception. Hierdoor is het mogelijk om altijd Exception te gebruiken om eender welke fout af te handelen. Echter is het gewenst dat men bij een stukje code de meest passende exception gebruikt. Indien men bv. de seriële poort wenst uit te lezen, kan men beroep doen op de IOException.

In een stukje code is het mogelijk dat er 2 of meer verschillende exceptions afgevuurd worden. In dat geval kan men, net zoals in een if-structuur verschillende catch-blokken achter elkaar programmeren.

try
{
	// some code for the IO ports
}
catch(IOException io_ex)
{
	MessageBox.Show("IO exception on port 1 occured: " + io_ex.Message);
}
catch(Exception ex)
{
	// print at least the message of the exception (outer exception)
	MessageBox.Show("Generic error occured: " + ex.Message);
}

In dat geval geldt dat men steeds de meest specifieke exception als eerst afhandelt, en dan pas overgaat naar de meer algemene exceptions. Er wordt in het geval van meerdere catch-blokken steeds maar 1 enkele blok uitgevoerd.

Het lijkt aanlokkelijk om alle code in te kapselen in een try-catch structuur. Het programma wordt hiermee inderdaad beveiligd tegen "falen". Echter moet men in het achterhoofd houden dat een try-catch blok veel resources van de computer vergt. Bovendien wordt de uitvoering van een stuk code hiermee onder controle gehouden waardoor de uitvoering ervan mogelijks trager zal verlopen. Try-catch blokken worden dus enkel gebruikt waar echt noodzakelijk.

Naast het kunnen afvangen van exceptions kan het ook handig zijn om zelf exceptions af te vuren (to fire an exception). Dit kan via onderstaand mechanisme:

if (something_went_wrong==true)
{
	throw new Exception("Something went terribly wrong here!");
}

In bovenstaand voorbeeld is een exception afgevuurd van het type Exception, met tussen de haken de boodschap van de fout. Men kan uiteraard een meer specifieke fout afvuren. Hiervoor volstaat het om het type van de fout aan te passen (bv. new IOException(...), enz.).

Als laatste wordt hier ook getoond hoe een eigen exception-klasse geschreven kan worden. Zoals eerder vermeld moet hiervoor de exception-klasse overgeërfd worden. Het gebruik ervan zou echter voor zichzelf moeten spreken.

using System;

//-- exception to describe we have been drawing out of the visible region of a canvas
public class DrawingOutOfBoundException: Exception
{
	protected int coord_x;
	protected int coord_y;
	protected bool coord_set = false;
	//---------------------------------------------------------------------
	public DrawingOutOfBoundException()
	{
		this.coord_set = false;
	}
	//---------------------------------------------------------------------
	public DrawingOutOfBoundException(string message): base(message)
	{
		this.coord_set = false;
	}
	//---------------------------------------------------------------------
	public DrawingOutOfBoundException(string message, Exception inner): base(message, inner)
	{
		this.coord_set = false;
	}
	//---------------------------------------------------------------------
	public DrawingOutOfBoundException(int xPosition, int yPosition)
	{
		this.coord_x = xPosition;
		this.coord_y = yPosition;
		this.coord_set = true;
	}
	//---------------------------------------------------------------------
	public override string Message // has ben set as virtual in exception
	{
		get
		{
			if (!coord_set) 
				return base.Message;
			else
				return "You have drawn out of the visible region of the canvas on position (" + coord_x +"," + coord_y + ")"; 
		}
	}
	//---------------------------------------------------------------------
}

De exception aanmaken kan dan als volgt:

if (x<0||x>100)
{
	throw new DrawingOutOfBoundException(x,y);
}

Het opvangen gebeurt als volgt:

try
{
	// try drawing here
}
catch(DrawingOutOfBoundException ex)
{
	// print at least the message of the exception
	MessageBox.Show(ex.Message);
}

Ook hier gelden de regels van de overerving! Enkel methoden zichtbaar in de meest gespecialiseerde exception zullen pas bereikt kunnen worden indien men de exception van op z'n minst die exception declareert.

TryParse

In het begin werd vermeld dat de gebruiker foutieve inputs kan leveren. Een mogelijke versnelde manier om te achterhalen of bv. een ingevoerd weldegelijk een getal is, is door gebruik te maken van de methode TryParse. Deze methode kijkt na of een getal omgezet kan worden en retourneert true indien dit mogelijk is. Dit wordt hieronder geïllustreerd.

int result;
if (!int.TryParse(txtAge.Text, out result))
{ 
	MessageBox.Show("Could no convert value of input text \"Age\"");
}

Les 6

Theorie: bestanden

Opgave WPO6

Bijlagen WPO 6

Als laatste zullen we binnen het objectgeoriënteerd programmeren het uitlezen van bestanden, het schrijven naar bestanden en het geformateerd lezen en schrijven behandelen. In onderstaande codefragmenten wordt aangehaald hoe de verschillende operaties (read,write en append) tewerk gaan. De append-methode is een variant op het gewone schrijven, met als voornaamste verschil dat de append automatisch data achteraan het bestand toevoegt, terwijl het gewoon schrijven naar een bestand eerst het bestand volledig wist, en dan pas data in het bestand schrijft. De append-methode komt overeen met een += operatie bij een string.

private void btnRead_Click(object sender, EventArgs e)
{
	// Open the file to read from.
	try
	{
		// generate string for the current file name
		// note the '@' at the beginning
		string path = @"E:\Bijlagen_WPO4\test.txt";
		using (StreamReader sr = File.OpenText(path))
		{
			string s = "";
			// read line by line until the end of file (s==null) 
			// has been reached
			while ((s = sr.ReadLine()) != null)
			{
				// write the current line in a textbox.
				txtOutput.Text += s + "\r\n";
			}
		}
	}
	catch (Exception ex)
	{
		MessageBox.Show(ex.Message);
	}
}
// write data to a file
private void btnWrite_Click(object sender, EventArgs e)
{
	try
	{
		string path = @"E:\Bijlagen_WPO4\test.txt";
		using (StreamWriter sw = File.CreateText(path))
		{
			sw.Write(txtOutput.Text);                    
		}
	}
	catch (Exception ex)
	{
		MessageBox.Show(ex.Message);
	}
}
// append data to a file
private void btnAppend_Click(object sender, EventArgs e)
{
	try
	{
		string path = @"E:\Bijlagen_WPO4\test.txt";
		using (StreamWriter sw = File.AppendText(path))
		{
			sw.Write(txtOutput.Text);
		}
	}
	catch (Exception ex)
	{
		MessageBox.Show(ex.Message);
	}
}

Merk op dat de strings die gebruikt worden als paden voor het filesysteem altijd voorafgegaan worden door een apenstaart. Paden worden in Windows vaak met het teken "\" aangeduid. Dit teken is echter ook een stringcommando in C# (\n, \r, \t). Om het stringcommando uit te schakelen wordt de string voorafgegaan door dit apenstaart. Een andere manier is om echter die commando uit te schakelen door een dubbele slash te gebruiken ("\\"). In dat geval wordt het apenstaart vooraan weggelaten. Hieronder wordt dezelfde string gebruikt op de 2 manieren.

// first way of decalring a string for path
string path = @"E:\Bijlagen_WPO4\test.txt";
// second way to do it
string path = "E:\\Bijlagen_WPO4\\test.txt";

CSV-formaat

Lezen, schrijven en data toevoegen zijn de basisbehandelingen die men kan uitvoeren op een bestand. Indien men veel data met een regelmatig patroon wenst op te slaan, kan het handig worden om een bepaalde opslagconventie te hanteren. Een voorbeeld van zo'n conventie is het CSV-formaat. Het CSV-formaat ("comma separated values") wordt gekenmerkt door het opslaan van data die gescheiden zijn door komma's. Door op elke regel maar een bepaalde set van waardes op te slaan, en dit zo op verschillende regels te hanteren, kan men grote datasets aanmaken. Zo bijvoorbeeld kunnen meetresultaten opgeslagen worden. Het CSV-formaat wordt als volgt toegepast: de eerste rij bevat de namen van de kolommen, waarij de namen gescheiden zijn door komma's. De volgende rijen bevatten de data (numerieke data, tekst, enz.), eveneens gescheiden door komma's.

Het lezen uit zo'n bestand verschilt niet veel van het lezen uit een random tekstbestand. Lees eerst regel per regel in. Als dit lukt, is de volgende stap het afzonderen van de data van de komma's. Hiervoor kan je de methode "Stringsplit" toepassen (met de komma als delimiter). Deze methode retourneert een aantal strings terug, waarbij het aantal overeenkomt met het aantal data per regel. Nu is het de moment om al deze afzonderlijke strings naar het juiste datatype om te zetten (int, float, double, enz.). De volgorde van de data (plaats in de regel) is hier zeer belangrijk, en wordt bepaald door de kolomnaam ("Caption").

Het schrijven naar een CSV-bestand kan op een eenvoudige wijze plaatsvinden. Voor elke regel maak je een lege string aan. Deze vul je op door alternerend de juiste waarde van van huidige kolom erin te schrijven, tesamen met een komma. Elke regel (string) sluit je af met een newline! Deze newline wordt bij het lezen gebruikt als einde voor de "Readln" methode, en duidt ook aan dat een gegeven dataset afgesloten is. Via de StreamWriter kan je de string toevoegen aan het bestand. Vergeet niet: de eerste regel bevat de kolomnamen!

Als voorbeeld wordt het uitlezen van een CSV-bestand gedemonstreerd. De code berekent het gemiddelde van alle waarden op eenzelfde lijn die door een kommas gescheiden worden.

// Open the file to read from.
try
{
	// generate string for the current file name
	// note the '@' at the beginning
	string path = @"E:\Bijlagen_WPO4\test.txt";
	using (StreamReader sr = File.OpenText(path))
	{
		string s = "";
		int linenumber = 1;
		// read line by line until the end of file (s==null) 
		// has been reached
		if ((s = sr.ReadLine())!=null)
		{
			// read caption here
		}
		while ((s = sr.ReadLine()) != null)
		{
			// split the values by ','
			csv_values = s.Split(',');
			int sum=0;
			for (int i=0;i<csv_values.Length;i++)
			{
				int value = int.Parse(csv_values[i]);
				sum+=value;
			}
			int average = sum/csv_values.Length;
			txtOutput.Text += "Average on line " + linenumber + " = " + average.ToString() + "\n";
			linenumber++;
		}
	}
}
catch (Exception ex)
{
	MessageBox.Show(ex.Message);
}

Configuratiebestanden

Een ander type betanden dat regelmatig gebruikt wordt, is het configuratiebestand. Deze bestanden laten toe om een programma op eenvoudige wijze een aantal paramters mee te geven voor een correcte en gewenste werking. Een configuratiebestand kan er volgt uitzien:

OS=LINUX
DEVICE=/dev/ttyUSB0
DEVICEHIGH=/dev/ttyUSB1
LINES=30
COMMAND=RUN
TIME=14:15:00

Het eigenlijke format kan licht veranderen van software van software. Echter is er steeds een recurrent patroon: links van het scheidingsteken vindt men de naam van de configuratie (vaak in hoofdletters), rechts van het scheidingsteken vindt men de parameters (0 of meerdere). Over het algemeen vindt men maar 1 configuratie-commando per regel. Het is dus van belang om dit type bestanden eerst met een externe editor te analyseren alvorens men een gepaste reader gaat programmeren. Men kan dus een stringsplit-operatie toepassen om de naam van de parameters te kunnen afzonderen (bv. OS en LINUX). Nadien kan men met een 2de stringsplit de paramters onderling (indien van toepassing) van elkaar verder afzonderen (bv. 14, 15 en 00). Het afzonderen van de verschillende paramters gebeurt best in een grote if-elsif structuur. Een else bij deze structuur mag enkel gebruikt worden om standaardwaarden in te vullen. Ga er in je eigen software steeds van uit dat een ontbrekende waarde in een bestand steeds overeenkomt met een standaardwaarde! Dit vermijd achteraf problemen in het verdere verloop van het programma. Dergelijke bestanden eindigen vaak met de extensie *.conf of *.config.