|
Nemerle jest nowym językiem opartym o platformę .NET. Stanowi on połączenie popularnych języków obiektowych, takich jak C#, Java, C++ z elementami o bardziej akademickim charakterze -- polimorfizmem, funkcjami traktowanymi jako zwykły obiekt, abstrakcyjnymi typami danych i systemem ich rozbioru, automatyczną rekonstrukcją typów i bardzo rozbudowanym systemem makr.
Tworząc język, staraliśmy się wprowadzić najlepsze według nas cechy tzw. ,,programowania funkcjonalnego'' do szerszego środowiska programistów. Najnowsze wersję języków Java (wydana niedawno 1.5) i C# (wersja 2.0, częściowo zaimplementowana już w mono) podążają tym samym tropem, dodając niektóre z wymienionych wyżej elementów. My postanowiliśmy pójść trochę dalej i zacząć od tego, czym języki komercyjne staną się prawdopodobnie za kilka lat, dorzucając do tego jeszcze kilka naszych pomysłów.
Wiele cech języka bierze swój początek w istniejących już dość długo pomysłach, które najczęściej jednak pozostawały w użyciu tylko w wąskim gronie entuzjastów SML-a, Ocamla czy Haskella. Nemerle ma natomiast tę zaletę, że składniowo bardzo przypomina popularne języki, które wiele osób dobrze zna. Programista może używać stylu, do którego przywykł, odkrywając po jakimś czasie jak napisać pewne (często bardziej skomplikowane) programy w wygodniejszy niż dotąd sposób.
Przyjrzyjmy się konstrukcji języka, na przykładzie programu wczytującego dwie liczby i wypisującego ich sumę. Wygląda on następująco:
using System; public class Adder { static Ilosc (x : int, y : int) : int { x + y } public static Main () : void // statyczna metoda Main jak w C# { Console.WriteLine ("Ala ma {0} kotów", Ilosc (Int32.Parse (Console.ReadLine ()), Int32.Parse (Console.ReadLine ()))); } } |
Charakterystyczne dla Nemerle jest umieszczenie typu metody po jej nagłówku, podobnie jak typów parametrów, za oddzielającym :. Konwencja ta ma taką zaletę, że w niektórych sytuacjach możemy w ogóle opuścić typ, a kompilator sam się go ,,domyśli''. Zachowujemy w ten sposób spójność składni i wyraźne oddzielenie typów od reszty języka.
Jak widać w metodzie Ilosc, omijamy pisanie słowa kluczowego return zwracając rezultat obliczeń. To uproszczenie ma sens, ponieważ wartość ostatniego wyrażenia zwracana jest jako rezultat wykonania sekwencji wyrażeń. Idea jest taka, aby całkiem wyeliminować instrukcję skoku.
Często wykonując skomplikowane obliczenia operujemy na wartościach obliczanych raz, wykorzystywanych następnie do kolejnych obliczeń. Wymaga to deklarowania wielu zmiennych lokalnych, dla każdej z pośrednich wartości. Gdy mają one skomplikowane typy, staje się to dość uciążliwe. W Nemerle deklaracja zmiennych ma nieco zmienioną postać:
class IloscLini { public static Main () : void { // Otwieramy plik. def sr = System.IO.StreamReader ("Jakis plik.txt"); // (1) mutable line_no <- 0; // (2) mutable line <- sr.ReadLine (); while (line != null) { // (3) System.Console.WriteLine (line); line_no <- line_no + 1; // (4) line <- sr.ReadLine (); }; // (5) System.Console.WriteLine ("Ilosc lini: {0}", line_no); } } |
Zamiast pisać przed zmienną jej typ, po prostu definiujemy jaka wartość ma być związana z danym identyfikatorem. Wykorzystujemy tutaj fakt, że kompilator łatwo może odtworzyć typ wyrażenia. Własność ta nazywana posiada mądrą nazwę rekonstrukcji lub też inferencji typów.
Pomiędzy (1) i (2) widać rozróżnienie na definicje wartości (stałych) i zmiennych, którym podczas wykonania można przypisać inną zawartość. Zauważmy, że operatorem przypisania jest <-, zaś def
= ... służy jedynie do jednorazowej deklaracji.Podobnie jak tymczasowe wartości, często chcielibyśmy stworzyć i użyć niewielkich algorytmów czy przekształceń w trakcie obliczeń. Potrzeba definiowania kolejnej metody prywatnej gdzieś w bieżącej klasie zniechęca do takiego podejścia. Rezultatem są różne nienaturalne triki i nieczytelny kod lub (w przypadku tworzenia dodatkowych metod) duże rozproszenie kodu.
W Nemerle możemy definiować funkcje lokalnie w dowolnym miejscu kodu, wiążąc lepiej algorytm z miejscem jego użycia.
def dodaj (lista : ListView, x, y) { lista.Items.Add(ListViewItem(x, y)) lista.Sorting = SortOrder.Ascending; }; dodaj (ListView1, "test", 0); dodaj (ListView2, "nastepny", 0); dodaj (ListView3, "koniec", 0); |
Tutaj także widzimy rekonstrukcję typów, gdyż zamiast pisać
def dodaj (lista : ListView, x : string, y : int) : void |
opuściliśmy oznaczenia typów, pozwalając kompilatorowi na odtworzenie ich automatycznie. Nie zawsze jest to możliwe -- opuszczenie typu dla parametru lista spowoduje, że w definicji funkcji będzie wiadomo jedynie, że jest to jakaś klasa z polem Items. Najczęściej jednak nie jest to problemem, gdyż podanie typu wymagane jest przeważnie tylko w przypadkach, w których to samo w sobie dobrze dokumentuje kod.
Co więcej, możemy używać zdefiniowanej funkcji jak zwykłego obiektu, który gdzieś przekazujemy i używamy w zupełnie innym kontekście.
def dodajmoj (x) { dodaj (ListView1, x, 0) }; WykonajPotem (dodajmoj, 1000); // uruchom funkcję za 1000ms |
Operując nawet na dość prostych strukturach danych jak lista czy tablica, spotykamy się z problemem pisania uniwersalnych funkcji na nich operujących. Zwykłe sortowanie czy przepisanie elementów z drzewa do listy wymaga pisania osobnych funkcji dla każdego typu -- liczb, napisów, obiektów jakiejś klasy.
Języki obiektowe radzą sobie z tym najczęściej przez definiowanie struktur i metod operujących na najbardziej ogólnym obiekcie (np. System.Object w .NET), po którym dziedziczą wszystkie inne. Wymaga to wykonywania jawnych lub niejawnych rzutowań w wielu miejscach kodu, w czym można łatwo się pomylić, a co za tym idzie otrzymać błąd w trakcie wykonania programu. Poza tym można szybko się zgubić, kiedy operujemy na kilku różnych typach, które musieliśmy wszystkie zastąpić przez Object.
Rozwiązaniem są tzw. ,,typy polimorficzne''. Każdy typ może być pod niego podstawiony, byle tylko w każdym miejscu ten sam. Rozpatrzmy klasę opisującą tablicę haszującą.
public class Hashtable <'a,'b> : IDictionary <'a,'b> { public Add (key : 'a, val : 'b) : void ... |
'a i 'b są właśnie takimi typami. Implementując metody tej klasy, nie interesuje nas typ elementów, na których ona operuje. Ważne jest, aby za każdym wywołaniem metod Add, Get, itd. były one spójne.
Podejście to jest podobne do tego, które zostanie dodane do C# w wersji 2.0 (.NET 1.2). Co więcej, wraz z jej pojawieniem się, polimorfizm zostanie dodany do samego środowiska uruchomieniowego .NETu, co pozwoli na bardziej efektywne wykonywanie programów, niż obecna implementacja za pomocą System.Object.
Jednym z najciekawszych elementów Nemerle jest system makr. Choć idea jest zbliżona, nie należy go mylić z makrami preprocesora w C. Jest dużo silniejszy, gdyż umożliwia wykonywanie dowolnego kodu podczas kompilacji programu.
Można w ten sposób generować nowy kod, przekształcać go, analizować, jeszcze zanim wynikowy program zostanie uruchomiony. Obliczenia te są całkowicie parametryzowalne danymi zewnętrznymi, zatem możemy stworzyć program na podstawie pliku XML lub bazy danych, wygenerować i wpisać w kod tablicę wartości funkcji trygonometrycznych lub nawet napisać makro dodające do różnych klas nowe metody.
Jako przykład rozpatrzmy typową pętlę for. Bierze ona trzy parametry i wykonuje podany potem fragment kodu. Poniższe makro (używane w samym kompilatorze Nemerle) przekształca konstrukcję takiej pętli na funkcję lokalną wywoływaną rekurencyjnie.
macro for (init, cond, change, body) syntax ("for", "(", init, ";", cond, ";", change, ")", body) { <[ $init; def loop () : void { if ($cond) { $body; $change; loop() } else () }; loop () ]> } |
Wprowadzone tutaj specjalne nawiasy <[ ... ]> oznaczają ,,zacytowanie'' części kodu. To co znajduje się wewnątrz nich jest programem, który zostanie wygenerowany. Makra operują na drzewach składniowych programu, czyli reprezentacjach kodu wewnątrz kompilatora. Tutaj init, cond, itd. są parametrami, których wartością reprezentują fragmenty programu. Zapisanie przed zmienną $ wewnątrz ,,cytowań'' oznacza, że w tym miejscu zostanie wstawiony fragment programu, który jest wartością tej zmiennej.
Słowo syntax definiuje rozszerzenie składni języka - zapisując powyższe makro dodajemy do kompilatora regułę rozbioru składniowego konstrukcji for.
System makr okazał się niezwykle użyteczny podczas tworzenia kompilatora. Co więcej, mamy mnóstwo pomysłów na ich rozbudowę i na użyteczne makra, np.:
Pierwsza oficjalna wersja kompilatora ukazała się 17 lutego 2004 roku. Język zapewne będzie jeszcze dopracowywany w różnych szczegółach, w czym liczymy na pomoc i cenne opinie od użytkowników.
Tworzenie kompilatora odbywa się równolegle pod Windowsem w środowisku .NET 1.1 oraz pod Linuxem z wykorzystaniem Mono (0.29 i 0.30).