středa 16. března 2016

Příklad na pohovor s programátorem - hotovost v pokladně

Na blogu jsem uveřejnil několik příkladů z pohovorů s uchazeči o místo programátora. Dovolím si tedy uveřejnit jeden z dalších možných příkladů, se kterým se lze setkat - na předešlé příspěvky jsem dostal emailem poměrně dost reakcí a překvapilo mne, že poměrně hodně lidí považuje příklady za jednoduché. Těžko to soudit, ale z mých několikaletých zkušeností ze zadávání podobných úkolů zvládne zadání implentovat méně než třetina kandidátů - a  zcela uspokojivých jsou pak tak jedno až dvě řešení z deseti.  Ono v poklidu u pracovního stolu vše vypadá jinak.



Nejsem příznivcem zadávání "domácích" ukolů a tak se spíše spoléhám na to, co se ukáže při pohovoru a  snažím se  sledovat reakce a chování programátora. Ale někdo jiný může mít na tohle jiný názor.

Takže zpět k příkladu - úkolem je navrhnout třídu či třídy, které by sloužily k sledování hotovosti na pokladně - tedy kolik bankovek a mincí v pokladně máme. Třída by měla umožňovat příjem a výdej hotovosti.

První kroky

Zadání je velmi obecné a očekává se, že se bude upřesňovat na základě otázek. Pokud se tedy uchazeč "chytne". Obvykle pak následuje první pokus  řešení - použíje se instance  třídy Dictionary, klíčem je typ bankovky či mice, hodnotou pak jejich počet. Takže nejprve se napíše  výčet  všech možných platidel  (než se začne psát, je dobré si tedy napřed vyjasnit, pro jakou měnu kód píšeme):

public enum CzechMoney
{
   Kc1= 1,
   Kc2 = 2,
   Kc5 = 3,
   Kc10 = 4,
   Kc20 = 5,
   Kc50 = 6,
   Kc100 = 7,
   Kc200 = 8,
   Kc500 = 9,
   Kc1000 = 10,
   Kc2000 = 11,
   Kc5000 = 12
}
a stav "pokladny" lze držet v instanci  Dictionary:

Dictionary<CzechMoney, int> cash = new Dictionary<CzechMoney, int>();

S takovýmto základem následuje obyčejně napsání vlastní třídy, která umožní bezpečnější práci. Mimochodem se tak i ukáže, zda se programátor někdy setkal s indexery.  Při psaní takové třídy je obvykle dobrým znamením, pokud si programátor ujasňuje se zadavatelem vyžadované chování  - například co se má stát, pokud se pokusíme zadat zápornou hodnotu - zda to má kód vůbec umožnit a tedy zda by místo datového typu  int nebylo lepší použít typ  uint a podobně.

Převedení do třídy

Do poklady bychom také chtěli přidávat a nebo z ní vybírat hotovost - což je opět příležitost pro kladní otázek, například jak by se měla třída zachovat při pokusu vybrat z ní více, než v ní je - je možné takový stav potichu ignorovat, vyvolat vyjímku a nebo implementovat metody TryRemove apod.
public class CzechCash
{
   private readonly Dictionary<CzechMoney, uint> cash = new Dictionary<CzechMoney, uint>();

   public uint this[CzechMoney what]
   {
      get
      {
         if (this.cash.ContainsKey(what))
            return this.cash[what];

         return 0;
      }

      set
      {
         if (!this.cash.ContainsKey(what))
            this.cash.Add(what, value);
         else
            this.cash[what] = value;
      }
   }

   public void Add(CzechCash anotherCash)
   {
      foreach (var what in anotherCash.cash.Keys)
      {
         this[what] += anotherCash[what];
      }
   }

   public void Remove(CzechCash anotherCash)
   {
      foreach (var what in anotherCash.cash.Keys)
      {
         this[what] -= anotherCash[what];
      }
   }
}

Testování

V tomhle bodě je už dobré, aby se doplnily i testy.  Někdo to začně dělat sám od sebe, někdy si napíše jednoduchý kód pro otestování v hlavním kódu (metoda Main) a  třeba později jej přesune do unit testových tříd, občas zůstane jen u toho - lze z toho usoudit na pokročilost programátora.

Pokud programátor zapíše pro otestování jen spustitelný kód a spokojí se s tím, že mu projde, není to dobré znamení. Například podobný kód neodhalí závažnou chybu, která v kódu je:

class Program
{
   static void Main(string[] args)
   {
      var cash1 = new CzechCash();
      cash1[CzechMoney.Kc10] = 5;
      cash1[CzechMoney.Kc100] = 4;
 
      var cash2 = new CzechCash();
      cash2[CzechMoney.Kc10] = 8;
 
      cash1.Remove(cash2);
   }
}

Tenhle kód bez chyby proběhne a může vyvolat mylný dojem, že vše je v pořádku. I proto je lepší spoléhat na unit testy, testování pomocí nich  může  pro třídu CzechCash probíhat takto (je použit MSTest framework, v jiných by zápis vypadal samozřejmě jinak) :

[TestClass()]
public class CzechCashTests
{
   [TestMethod()]
   public void AddTest()
   {
      var cash1 = new CzechCash();
      cash1[CzechMoney.Kc10] = 5;
      cash1[CzechMoney.Kc100] = 4;
 
      var cash2 = new CzechCash();
      cash2[CzechMoney.Kc10] = 8;
 
      cash1.Add(cash2);
 
      Assert.AreEqual(cash1[CzechMoney.Kc10], 13U);
      Assert.AreEqual(cash1[CzechMoney.Kc100], 4U);
 
   }
 
   [TestMethod()]
   public void RemoveTest()
   {
      var cash1 = new CzechCash();
      cash1[CzechMoney.Kc10] = 5;
      cash1[CzechMoney.Kc100] = 4;
 
      var cash2 = new CzechCash();
      cash2[CzechMoney.Kc10] = 8;
 
      cash1.Remove(cash2);
 
      Assert.AreEqual(cash1[CzechMoney.Kc10], 0U);
      Assert.AreEqual(cash1[CzechMoney.Kc100], 4U);
   }
}

Obvykle programátor zapíše tyhle testy bez U u číselných hodnot, na tuto chybu po spuštění testu poměrně rychle přijde.

Ošetření nalezených chyb

Následně ale po spuštění takového testu rychle přijde i  na to,  že použití datového typu uint není zcela bez následku a vedlejších efektů -   pokud odečteme více hotovosti, než máme,  tak získáme "v pokladně" více peněz - právě k tomu jsou dobré unit testy, aby odhalily podobné chyby. Kód tak stačí upravit  a dojde tak k vyhození vyjímky při přetečení rozsahu typu  uint:

public void Remove(CzechCash anotherCash)
{
   foreach (var what in anotherCash.cash.Keys)
   {
      this[what]  = checked(this[what] - anotherCash[what]);
   }
}
Samozřejmě je nutné i upravit testovací metodu, aby vyjímku považovala za správné chování:

[TestMethod()]
[ExpectedException(typeof(System.OverflowException))]
public void RemoveTest()
{
   var cash1 = new CzechCash();
   cash1[CzechMoney.Kc10] = 5;
   cash1[CzechMoney.Kc100] = 4;
 
   var cash2 = new CzechCash();
   cash2[CzechMoney.Kc10] = 8;
 
   cash1.Remove(cash2);
}

Vhodné je taktéž přidat další testování, například metodu:

[TestMethod()]
 
public void RemoveTest2()
{
   var cash1 = new CzechCash();
   cash1[CzechMoney.Kc10] = 5;
   cash1[CzechMoney.Kc100] = 4;
 
   var cash2 = new CzechCash();
   cash2[CzechMoney.Kc10] = 3;
 
   cash1.Remove(cash2);
 
   Assert.AreEqual(cash1[CzechMoney.Kc10], 2U);
}

Jak si lze všimnout, tak pro pojmenování testovacích metod nepoužívám příliš výstížné názvy. Vhodné by bylo použít nějaký pojmenovávací vzor, například RemoveCash_ThrowException_WithdrawToMinus - shrnutí vhodných konvencí pro pojmenování lze nalézt například na  https://dzone.com/articles/7-popular-unit-test-naming

Podpora sčítání a odečítání

Dalším požadavkem je podpora základních aritmetických operací, chtěli bychom dvě hotovosti sčítat či odečítat, tady mít možnost napsat takovýto kód: var cash = cash1 + cash2;

Zde se ukáže, zda programátor tuší, že lze toto implementovat a jak toho dosáhnout:

public static CzechCash operator +(CzechCash mc1, CzechCash mc2)
{
   var result = new CzechCash();
 
   var whats = mc1.cash.Keys.Union(mc2.cash.Keys);
 
   foreach (var what in whats)
   {
      result[what] = mc1[what] + mc2[what];
   }
 
   return result;
}
 
public static CzechCash operator -(CzechCash mc1, CzechCash mc2)
{
   var result = new CzechCash();
 
   var whats = mc1.cash.Keys.Union(mc2.cash.Keys);
 
   foreach (var what in whats)
   {
      result[what] = checked(mc1[what] - mc2[what]);
   }
 
   return result;
}


Opět by měly být doplněny testy, tedy například tento kód:

[TestMethod]
public void AddCash()
{
   var cash1 = new CzechCash();
   cash1[CzechMoney.Kc10] = 5;
   cash1[CzechMoney.Kc100] = 4;
 
   var cash2 = new CzechCash();
   cash2[CzechMoney.Kc10] = 3;
 
   var cash = cash1 + cash2;
 
   Assert.AreEqual(cash[CzechMoney.Kc10], 8U);
   Assert.AreEqual(cash[CzechMoney.Kc100], 4U);
}


Celková suma

Nyní lze požadovat kód, který by co nejjednodušeji vracel celkovou hodnotu hotovosti v pokladně. Možným jednoduchým řešením je vrátit se k definici všech platidel a takto ji upravit:

public enum CzechMoney
{
   Kc1 = 1,
   Kc2 = 2,
   Kc5 = 5,
   Kc10 = 10,
   Kc20 = 20,
   Kc50 = 50,
   Kc100 = 100,
   Kc200 = 200,
   Kc500 = 500,
   Kc1000 = 1000,
   Kc2000 = 2000,
   Kc5000 = 5000
}
A poté doplnit do třídy CzechCash následující metodu:

public static implicit operator decimal(CzechCash mc)
{
   decimal sum = 0;
   foreach (var what in mc.cash.Keys)
      sum += (int) what * mc.cash[what];
 
   return sum;
}
a tu si i ihned otestovat:

public void CashSum()
{
   var cash1 = new CzechCash();
   cash1[CzechMoney.Kc10] = 5;
   cash1[CzechMoney.Kc100] = 4;
 
   Assert.AreEqual((decimal)cash1, 450);
}

Poznámka - vše lze naprogramovat jednodušeji, pokud uchazeč ovládá Linq, tak místo několika řádků vyjádří vše jedním příkazem: return mc.cash.Sum(c => (int) c.Key * c.Value);

 

A co dál?

Samozřejmě, že zde práce nekončí, uchazeče lze požádat o řešení například pro euro, třídu lze následně upravit na gererickou. Dále lze mít informace o měnách uloženy v databázi a začít se bavit o IoC a DI a jak to vše dát dohromady - zkrátka na jednoduchém zadání lze vystavět poměrně pokročilé řešení.
V uvedeném kódu mohou být chyby, je to skutečně jen ilustrace, jak lze k řešení úkolu přistoupit a jak lze rozsah zadání rozšiřovat a upřesňovat.

4 komentáře:

  1. uint neni CLS compliant typ, a takove typy nemusi implementovat kazdy .NET jazyk, takze pouzitim tohoto typu na verejnem rozhrani riskujete problemy pri volani vaseho rozhrani z jinych jazyku viz. https://msdn.microsoft.com/en-us/library/ax5w23a6.aspx

    OdpovědětVymazat
  2. Neriskuji nic, tohle vše je jen příklad :-). Ale díky za doplnění.

    OdpovědětVymazat
  3. No nevim, u mne by jste se s timhle na pohovoru neukazal jako opravdovy expert ale spise jako geek co zna "checked" a indexery. Samotna metoda Remove je ale pekna prasarna. Za prve by se nejdrive melo skontrolovat cele portfolio ze se muze vykonat jako transakce. Takhle se vam stane, ze cast cashe zapocitate do cilove cashe a kdyz se hodi exception, tak zbytek se nespracuje. Takze se vam vybere z pokladny jenom cast a mate to v nekonzistentim stavu.
    Taky je dobra praxe hazet specifickou exception a ne obecny Overflow exception. Takhle to pak pro callera vypada, ze v kodu je nejaka chyba a nedovi se proc vlastne tu operaci nemuze udelat.

    OdpovědětVymazat
  4. Máte ve svém komentáři pravdu a u pohovoru bych takové komentáře přivítal, ostatně je to zmíněno i v příspěvku ("...což je opět příležitost pro kladní otázek, například jak by se měla třída zachovat při pokusu vybrat z ní více, než v ní je - je možné takový stav potichu ignorovat, vyvolat vyjímku a nebo implementovat metody TryRemove...").

    Takže díky za podrobnější rozepsání toho, co by mohl uchazeč u pohovoru také uvést.

    OdpovědětVymazat