Anonymní delegáti vo foreach
Dnes to budú 2 tipy v jednom článku. Prvý problém je, ako načítať v LINQ 2 SQL asociované kolekcie. Mám entity Customer a Contact. A samozrejme Customer má kolekciu Contacts. A potrebujem v jednej metóde načítať zákazníkov aj s ich kontaktmi. Dá sa na to použiť trieda DataLoadOptions. Asi nasledovne:
var dlo = new DataLoadOptions();
dlo.LoadWith<Customer>(c => c.Contacts);
dataContext.LoadOptions = dlo;
return dataContext.Customers.ToList();
Toto zabezpečí, že v jednom SQL príkaze sa načítajú zákazníci aj ich kontakty. Čiže iba jeden dotaz na databázu, čo je oveľa efektívnejšie ako keby sa pre každého zákazníka nahrávali kontakty pomocou lazy-load metódy. Problém je ale v tom, že kontakty nepotrebujem načítať vždy, ale len v určitej metóde. A vlastnosť LoadOptions má nasledovné obmedzenia:
- Ak objekt DataLoadOptions priradím vlastnosti LoadOptions, tak tento objekt nemôžem meniť. To znamená, že nemôžem zavolať metódu LoadWith.
- Ak sa na danom DataContext objekte spustilo nejaké query, tak vlastnosť LoadOptions nie je možné zmeniť.
Našťastie kolekcia Contacts má metódu SetSource, čiže kontakty pre zákazníka môžem načítať z ľubovoľného zdroja typu IEnumerable<Contact>. Takže najprv načítam zákazníkov. Potom načítam všetky kontakty týchto zákazníkov do kolekcie List<Contact>. Sú to síce 2 dotazy na databázu namiesto jedného, ale stále je to konštantný počet oproti lineárnemu pri lazy-load. No a potom pre každého zákazníka stačí nastaviť zdroj kontaktov nie z databázy, ale z tohto listu.
/// <summary>
/// Loads the contacts for customers in one SQL statement.
/// </summary>
/// <param name="customers">The customers to load contacts for.</param>
/// <param name="customerQuery">The customer query. If null, then customers are selected from the list.</param>
public void LoadCustomerContacts(IList<Customer> customers, IQueryable<Customer> customerQuery)
{
if (customers == null)
{
throw new ArgumentNullException("customers");
}
IQueryable<Contact> contactsQuery = null;
if (customerQuery != null)
{
contactsQuery = Context.Contacts.Where(c => customerQuery.Any(cus => cus == c.Customer));
}
else
{
int[] customerIds = customers.Select(c => c.Id).ToArray();
contactsQuery = Context.Contacts.Where(c => customerIds.Contains(c.CustomerId));
}
var contacts = contactsQuery.ToList();
foreach (var customer in customers)
{
if (!customer.Contacts.HasLoadedOrAssignedValues)
{
customer.Contacts.SetSource(contacts.Where(c => c.CustomerId == customer.Id));
}
}
}
A tu som narazil na problém s anonymným delegátom alebo lambda funkciou vo foreach cykle. Teda sa dostávam k druhému bodu v tomto článku. Keď som túto metódu spustil, tak všetci zákazníci mali rovnakú množinu kontaktov a to boli kontakty posledného zákazníka. Síce som hneď pochopil, v čom je problém, ale aby som ho mohol vysvetliť, tak najprv vysvetlím ako fungujú anonymní delegáti. Lambda funkcia je len iný zápis anonymného delegáta, takže pre ňu to platí rovnako.
Predchádzajúca funkcia sa dá zapísať a preloží sa nasledovne. Najprv sa vygeneruje pomocná trieda, ktorá udržuje stav.
private class DelegateClass
{
public Customer customer;
public bool DelegateMethod(Contact c)
{
return c.CustomerId == customer.Id;
}
}
Potom sa foreach cyklus preloží nasledovne.
DelegateClass delegateStatus = new DelegateClass();
IEnumerator<Customer> enumerator = customers.GetEnumerator();
enumerator.Reset();
while (enumerator.MoveNext())
{
delegateStatus.customer = enumerator.Current;
if (!delegateStatus.customer.Contacts.HasLoadedOrAssignedValues)
{
delegateStatus.Contacts.SetSource(contacts.Where(delegateStatus.DelegateMethod));
}
}
Samozrejme, že tento môj preklad je len približný a hlavne som si vymyslel rozumnejšie názvy ako tie, čo generuje C# kompilátor. Ale dôležité je, že objekt typu DelegateClass sa vytvára iba raz pred vykonaním cyklu. Takže na konci majú všetky zdroje kontaktov v podmienke Where odkaz na ten istý objekt DelegateClass a teda toho istého zákazníka. A preto sa vždy načítajú len kontakty posledného zákazníka. Riešením je samozrejme presunúť riadok "DelegateClass delegateStatus = new DelegateClass()" do vnútra cyklu. Takže stačilo prepísať foreach cyklus nasledovne.
foreach (var customer in customers)
{
if (!customer.Contacts.HasLoadedOrAssignedValues)
{
int customerId = customer.Id;
customer.Contacts.SetSource(contacts.Where(c => c.CustomerId == customerId));
}
}
Alebo druhá možnosť je hneď za SetSource nahrať kolekciu kontaktov.
foreach (var customer in customers)
{
if (!customer.Contacts.HasLoadedOrAssignedValues)
{
customer.Contacts.SetSource(contacts.Where(c => c.CustomerId == customer.Id));
customer.Contacts.Load();
}
}
Záver je jednoduchý. Pri použití anonymných delegátov v cykle si treba vždy uvedomiť, kde sa vytvára inštancia stavového objektu delegáta. Aby sa nestalo, že rôzni delegáti zdieľajú ten istý stav.