A key advantage of using constructed classes is that you can embed a
construction sequence that describes the
steps required to construct an object of the given type. This construction sequence is a series of
let bindings (and possibly
do statements) that are in scope for the non-static members of the class. The following code refuses to create a polygon with fewer than three pairs of defining coordinates. (Note that you might just as easily log each polygon creation, keep a global counter of polygons created and refused, etc.):
type Polygon(coords) =
do if List.length coords < 3 then
failwith "Polygon must have at least 3 sides"
let length (x1:int) (y1:int) x2 y2 =
let cFloat (y: int) = Convert.ToDouble(y)
Math.Sqrt(cFloat(x2-x1)**2.0+cFloat(y2-y1)**2.0)
let last lst = coords |> List.rev |> List.hd
let _, perimeter = List.fold_left (fun ((x1, y1), accum) (x2, y2) ->
(x2, y2), length x1 y1 x2 y2 + accum) (last coords, 0.0) coords
interface IShape with
member self.Perimeter = perimeter
If the coordinates pass muster, you can calculate the perimeter of the polygon within the construction sequence. You do
that by folding over the polygon's coordinates (starting at the last one), calculating and then summing the length of each
side (the distance between two pairs of coordinates). Because the code performs the perimeter calculation each time you create a new polygon, you can access the result the
Perimeter (through
IShape) property in constant time.
You can quickly test this new class in F# Interactive:
> let p = Polygon([(10,20); (30, 40); (20, 30)]);;
val p : Polygon
> (p :> IShape).Perimeter;;
val it : float = 56.5685424
>
Adding Indexers to Your Classes
In essence, OO programming is about adding dot-notation to your values. You can further enhance the dot-notation you offer on your classes by adding indexers. Indexers provide a concise way to access (read or write) elements in a conceptual container. For instance, you may want to provide a mechanism to read the coordinates in a polygon via an index (starting at zero). You can easily implement this by enhancing the definition above as follows:
type Polygon(coords) =
...
member self.Item
with get(idx) =
if Seq.length coords <= idx then
failwith "Index out of bound"
else
Seq.nth idx coords
interface IShape with
member self.Perimeter = perimeter
Here, the
Item member provides the underlying mechanism for the
.[idx] syntax sugar to work:
> let p = Polygon([(10,20); (30, 40); (20, 30)]);;
val p : Polygon
> (p :> IShape).Perimeter;;
val it : float = 56.5685424
> p.[0];;
val it : int * int = (10, 20)
> p.[3];;
Microsoft.FSharp.Core.FailureException: Index out of bound
at FSI_0059.Polygon.get_Item(Int32 idx) in c:\demo1\Demo1\Program.fs:line 127
at <StartupCode$FSI_0063>.$FSI_0063._main()
stopped due to error
Named and Optional Arguments
You can make your code more readable by naming arguments in your calls. For instance, you may construct a new rectangle this way:
Rectangle(x1=10, y1=10, x2=20, y2=30)
The main advantage of naming arguments (besides readability) is that you can list the arguments in arbitrary order, making prototype changes in your functions easier. The compiler warns you if you forgot to include an argument or if you assigned a given parameter more than once.
Optional arguments enable you to omit certain parameters and typically assume default values for them. You can make an
argument optional by prefixing it with a question mark (?). As an example, you might want to equip your various
shape objects with the ability to draw themselves on a canvas. You can extend the
IShape interface
accordingly:
open System.Drawing
type IShape =
abstract member Perimeter : float
abstract member Draw : Graphics -> unit
Then you change the definition for circles by adding an optional color parameter (note that the preceding code fragment opens the
System.Drawing namespace to bring the
Color type into scope):
type Circle(r, x: int, y: int, ?color: Color) =
let Color =
match color with
| None ->
Color.Black
| Some c ->
c
interface IShape with
member self.Perimeter =
Convert.ToDouble (2*r) * System.Math.PI
member self.Draw g =
use pen = new Pen(Color)
g.DrawEllipse(pen, x-r, y-r, x+r, y+r)
Optional arguments are passed as F# option types, so the type of the
color parameter will be
Color option. In the construction sequence you check whether an argument was supplied for the
color, and if not you default to black (or any other color of your choice). Remember that the
Color binding in the construction sequence is visible in all non-static members, and indeed
you will use it in the
Draw method to draw a circle with the given color.
You can also patch up the rest of the shapes you worked with previously in a similar fashion (see
Listing 2).
To complete the example, you can now enhance your Drawing class to draw its shapes:
// A drawing is simply a sequence of shapes.
type Drawing = Drawing of seq
with
member self.TotalInkNeeded =
match self with
| Drawing shapes ->
shapes |> Seq.sum_by (fun s -> s.Perimeter)
member self.Draw g =
match self with
| Drawing shapes ->
shapes |> Seq.iter (fun s -> s.Draw g)
end
To test your code, the following snippet takes a drawing and displays it on a form:
open System.Windows.Forms
let drawing =
seq { for i in 1 .. 20
-> if i % 4 = 0 then
Circle (x=i, y=i, r=i, color=Color.Red) :> IShape
elif i % 4 = 1 then
Rectangle (x1=20, y1=20, x2=20+i*2, y2=20+i) :> IShape
elif i % 4 = 2 then
Rectangle.Square (20, 20, i) :> IShape
else
line (i, i, i+i, i+i) } |> Drawing
[<STAThreadAttribute()>]
let _ =
let form = new Form(Text="Example")
form.Paint.Add (fun args ->
let g = args.Graphics
drawing.Draw g)
Application.Run(form)
In this scenario, you used named and optional parameters for constructing circles and rectangles. The snippet responsible for drawing them created the main form and mutated it to have a given title in one step; this is a common shorthand notation for multiple property assignments that typically follow a UI object instantiation; in fact you can list further property-value pairs by separating them with commas.
The essence of the display logic is a two-line event handler—you simply bind to the Paint event a new handler that retrieves the form's graphics context and uses it to call the Draw method on the drawing, which in turn draws each shape in the drawing.
Conclusion
In this article you saw the basic object-oriented techniques that you can employ when developing object-oriented applications in F#. You saw how you can create abstract and derived classes to form class hierarchies via inheritance, how you can write "abstract" classes that implement various interfaces using constructed classes, and how you can implement object interfaces with object expressions that are specific to F#. You also used named and optional arguments on constructed class instances and added indexer notation to one of your classes. Finally, you developed a simple UI application to display a collection of shapes on a form.
These techniques augment your arsenal of functional programming constructs and enable you to interface with C# or VB code, implement .NET interfaces, or simply work with UI elements that are traditionally object-oriented.