(Don Box wrote an article in this month's MSDN Magazine called "Scheme Is Love" in which he did a brief whirlwind tour of the Scheme programming language.)
Back when gas was only $3 a gallon, if you wanted a programming language with anonymous types and type inferencing, you had to use one of them fancy languages like ML. I've actually written a few nontrivial programs in Standard ML and found the experience to be very enjoyable.
Static types without syntactic baggage. One of the major drawbacks of a dynamic language like Scheme is that the lack of strong types prevents the compiler from doing a certain class of work on your behalf - work you would instead have to do manually. But in exchange, you're saved some of the cognitive and syntactic load that most strongly typed languages have. In ML, you rarely "say" the name of a type in your code, so it reads like a dynamic language; but, because the compiler infers the types from usage, it can call you on any inconsistencies at compile time. ML has an interactive interpreter which prints out the names of the types that it infers.
- fun add3(x) = x + 3;
val add = fn : int -> int
In this case, the type inferencer is saying that "add3" is a function (fn) who maps (->) an int to an int. Now, if I try to misuse the add3 function by passing in the wrong type, the compiler throws an error:
- fun calladd3() = add3("spoon");
Error: operator and operand don't agree [tycon mismatch]
operator domain: int
operand: string
in expression:
add3 "spoon"
Expressive type system. Like Scheme, ML has a very simple-looking type system that is surprisingly powerful. Unlike Scheme, however, the compiler can optimize the data structures based on the static type to have a very efficient runtime representation. Also, the gain from having static type checking becomes even more apparent when you start dealing with complex data structures. The result is you get very expressive types that are efficient and safe.
Tuples and Records: You can create anonymous records by just declaring them, and of course records can contain other records:
- val rec = { firstname="Marc", lastname="Miller",
point = { x = 5, y = 7 } };
val rec = ... : {firstname:string, lastname:string,
point:{x:int, y:int}}
You can access a value from within a record by using the #fieldname(objectname) syntax:
- val pt = #point(rec);
val pt = {x=5,y=7} : {x:int, y:int}
And tuples are simply records whose elements are unnamed:
- val tuple = ("marc", "miller", ( 5, 7 ));
val tuple = ("marc","miller",(5,7)) : string * string * (int * int)
Datatype: The "datatype" keyword can be used to create named enumerations, like so:
- datatype KnownColor = Red | Green | Blue;
- fun GetGreen() = Red;
val GetGreen = fn : unit -> KnownColor
But you can also use it to create variant types:
- datatype Color = Known of KnownColor
| Custom of { red : int, green : int, blue : int };
- val blue = Known(Blue);
val blue = ... : Color
- val white = Custom({ red=0xf, green=0xf, blue=0xf });
val white = ... : Color
And datatypes, since they are named, can now contain members of their own type, which is very powerful because now you can create trees and linked lists:
datatype IntTree = IntNode of (IntTree * int * IntTree)
| Empty;
- IntNode(IntNode(Empty, 1, Empty), 2, IntNode(Empty, 3, Empty));
val it = ... : IntTree
There is also support for parameterized types, like generics or templates. So instead of making an IntTree, I could have made a more generic tree:
datatype ('T) Tree = Node of ('T Tree * 'T * 'T Tree)
| Empty;
- Node(Node(Empty, 1, Empty), 2, Node(Empty, 3, Empty));
val it = ... : int Tree
- Node(Node(Empty, "foo", Empty), "bar", Node(Empty, "snoo", Empty));
val it = ... : string Tree
And the type inferencer still polices everything, here I try to put a string into an int tree:
- Node(Node(Empty, "foo", Empty), 3, Empty);
Error: operator and operand don't agree [literal] ...
ML is "fun". ML's functions, like Scheme's, are first-class objects. This means they can be returned from other functions, stashed inside data structures, assigned to variables, etc. For example, I can make a function addn(n) that returns a function which will always add 'n' to the number passed into it:
- fun addn(n) = fn x => n + x;
val addn = fn : int -> (int -> int)
- val add5 = addn(5);
val add5 = fn : int -> int
- add5(4);
val it = 9 : int
Pattern matching on function arguments is another feature of ML's functions and will knock your socks off! I can write alternative versions of a functions implementation with patterns as arguments, and the pattern matcher will generate the logic to match the most specific one. For example, here's a version of factorial that uses patterns:
fun fact(0) = 1
| fact(n) = n * fact(n - 1);
Pattern matching is a great way to pull a value out of a datatype; here I walk an "int Tree" data structure, adding the elements in a depth-first traversal:
fun addTree(Empty) = 0
| addTree(Node(leftTree, data, rightTree)) = (data +
addTree(leftTree) +
addTree(rightTree));
- addTree(Node(Node(Empty, 1, Empty), 2, Node(Empty, 3, Empty)));
val it = 6 : int
In fact, ML functions only take one parameter, but pattern matching over tuples gives the illusion of multiple parameters:
- fun add(x,y) = x + y;
val add = fn : int * int -> int
This really says: "add" is a function that takes one parameter which is the tuple of type 'int * int', where 'x' and 'y' are bound to the two ints inside that tuple.
Know your heritage. This brief tour only touches the tip of the iceberg of what's available in ML. As you can see, C# 3.0 draws on some of the syntax and concepts of this language.
Type inferencing.
var x = 3; // C# 3.0
var x = 3; (* ML *)
Anonymous types:
var x = new { FirstName = "Marc", LastName = "Miller" }; // C# 3.0
var x = { FirstName : "Marc", LastName : "Miller" }; (* ML *)
Lambda expressions:
var add5 = x => x + 5; // C# 3.0
var add5 = fn x => x + 5; (* ML *)