Konvensi pengkodean F#

Konvensi berikut dirumuskan dari pengalaman bekerja dengan basis kode F# besar. Lima prinsip kode F# yang baik adalah dasar dari setiap rekomendasi. Mereka terkait dengan Panduan desain komponen F#, tetapi berlaku untuk kode F# apa pun, bukan hanya komponen seperti pustaka.

Kode pengaturan

F# memiliki dua cara utama untuk mengatur kode: modul dan kumpulan nama XML. Ini serupa, tetapi memiliki perbedaan berikut:

  • Kumpulan nama XML dikompilasi sebagai kumpulan nama XML .NET. Modul dikompilasi sebagai kelas statis.
  • Kumpulan nama XML selalu tingkat atas. Modul dapat menjadi tingkat atas dan bersarang di dalam modul lain.
  • Kumpulan nama XML dapat menjangkau banyak file. Modul tidak dapat.
  • Modul dapat didekorasi dengan [<RequireQualifiedAccess>] dan [<AutoOpen>].

Panduan berikut akan membantu Anda menggunakan ini untuk mengatur kode Anda.

Lebih menyukai kumpulan nama XML di tingkat atas

Untuk kode apa pun yang dapat dikonsumsi publik, kumpulan nama XML lebih disukai daripada modul di tingkat atas. Karena dikompilasi sebagai namespace .NET, namespace layanan tersebut dapat dikonsumsi dari C# tanpa menggunakan ke using static.

// Recommended.
namespace MyCode

type MyClass() =
    ...

Menggunakan modul tingkat atas mungkin tidak tampak berbeda ketika hanya dipanggil dari F#, tetapi untuk konsumen C#, penelepon mungkin terkejut dengan harus memenuhi syarat MyClass dengan MyCode modul ketika tidak mengetahui konstruksi C# tertentu using static .

// Will be seen as a static class outside F#
module MyCode

type MyClass() =
    ...

Terapkan [<AutoOpen>] dengan hati-hati

Konstruksi [<AutoOpen>] dapat mencemari cakupan dari apa yang tersedia untuk pemanggil, dan jawaban dari mana sesuatu berasal sifatnya "ajaib". Ini bukan hal yang baik. Pengecualian untuk aturan ini adalah F# Core Library itu sendiri (meskipun fakta ini juga agak kontroversial).

Namun, akan lebih mudah jika Anda memiliki fungsionalitas pembantu untuk API publik yang ingin Anda atur secara terpisah dari API publik tersebut.

module MyAPI =
    [<AutoOpen>]
    module private Helpers =
        let helper1 x y z =
            ...

    let myFunction1 x =
        let y = ...
        let z = ...

        helper1 x y z

Ini memungkinkan Anda memisahkan detail implementasi dengan rapi dari API publik suatu fungsi tanpa harus sepenuhnya membuat pembantu memenuhi syarat setiap kali Anda memanggilnya.

Selain itu, mengekspos metode ekstensi dan penyusun ekspresi di tingkat kumpulan nama XML dapat diekspresikan dengan rapi menggunakan [<AutoOpen>].

Gunakan [<RequireQualifiedAccess>] setiap kali nama dapat bertentangan atau Anda merasa bahwa ini dapat membantu keterbacaan

Menambahkan atribut [<RequireQualifiedAccess>] ke modul menunjukkan bahwa modul tidak dapat dibuka dan referensi ke elemen modul memerlukan akses yang memenuhi syarat secara eksplisit. Misalnya, modul Microsoft.FSharp.Collections.List memiliki atribut ini.

Ini berguna ketika fungsi dan nilai dalam modul memiliki nama yang cenderung bertentangan dengan nama di modul lain. Mengharuskan akses yang memenuhi syarat dapat sangat meningkatkan kemampuan pemeliharaan jangka panjang dan kemampuan pustaka untuk berkembang.

[<RequireQualifiedAccess>]
module StringTokenization =
    let parse s = ...

...

let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'

Urutkan pernyataan open secara topologis

Dalam F#, urutan deklarasi penting, termasuk dengan open pernyataan (dan open type, hanya disebut sebagai open lebih jauh ke bawah). Ini tidak seperti C#, di mana efek using dan using static tidak tergantung pada urutan pernyataan tersebut dalam file.

Di F#, elemen yang dibuka ke dalam cakupan dapat membayangi elemen lain yang sudah ada. Ini berarti bahwa menyusun ulang pernyataan open dapat mengubah arti kode. Akibatnya, pengurutan arbitrer dari semua pernyataan open (misalnya, secara alfanumerik) tidak disarankan, agar Anda tidak menghasilkan perilaku berbeda dari yang Anda harapkan.

Sebagai gantinya, kami menyarankan Anda untuk mengurutkannya secara topologis; artinya, urutkan pernyataan open Anda dalam urutan di mana lapisan dari sistem Anda ditentukan. Melakukan pengurutan alfanumerik dalam lapisan topologis yang berbeda juga dapat dipertimbangkan.

Misalnya, berikut adalah pengurutan topologis untuk file API publik layanan pengompilasi F#:

namespace Microsoft.FSharp.Compiler.SourceCodeServices

open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text

open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library

open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver

open Internal.Utilities
open Internal.Utilities.Collections

Pemisah baris memisahkan lapisan topologis, dengan setiap lapisan diurutkan secara alfanumerik setelahnya. Ini dengan rapi mengatur kode tanpa secara tidak sengaja membayangi nilai.

Gunakan kelas untuk memuat nilai yang memiliki efek samping

Sering kali, inisialisasi nilai dapat memiliki efek samping, seperti membuat instans konteks ke database atau sumber daya jarak jauh lainnya. Sangat menggiurkan untuk menginisialisasi hal-hal seperti itu dalam sebuah modul dan menggunakannya dalam fungsi-fungsi berikutnya:

// Not recommended, side-effect at static initialization
module MyApi =
    let dep1 = File.ReadAllText "/Users/<name>/connectionstring.txt"
    let dep2 = Environment.GetEnvironmentVariable "DEP_2"

    let private r = Random()
    let dep3() = r.Next() // Problematic if multiple threads use this

    let function1 arg = doStuffWith dep1 dep2 dep3 arg
    let function2 arg = doStuffWith dep1 dep2 dep3 arg

Ini sering bermasalah karena beberapa alasan:

Pertama, konfigurasi aplikasi didorong ke dalam basis kode dengan dep1 dan dep2. Ini sulit dipertahankan dalam basis kode yang lebih besar.

Kedua, data yang diinisialisasi secara statis tidak boleh menyertakan nilai yang tidak aman untuk utas jika komponen Anda sendiri akan menggunakan beberapa utas. Ini jelas dilanggar oleh dep3.

Terakhir, inisialisasi modul dikompilasi menjadi konstruktor statis untuk seluruh unit kompilasi. Jika ada kesalahan yang terjadi dalam inisialisasi nilai let-bound dalam modul itu, kesalahan tersebut muncul sebagai TypeInitializationException yang kemudian di-cache untuk seluruh masa pakai aplikasi. Ini bisa sulit didiagnosis. Biasanya ada pengecualian dalam yang dapat coba dinalar, tetapi jika tidak ada, maka tidak ada yang memberi tahu apa akar penyebabnya.

Sebagai gantinya, cukup gunakan kelas sederhana untuk menyimpan dependensi:

type MyParametricApi(dep1, dep2, dep3) =
    member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
    member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

Hal ini memungkinkan berikut:

  1. Mendorong status dependen apa pun di luar API itu sendiri.
  2. Kini konfigurasi dapat dilakukan di luar API.
  3. Kesalahan dalam inisialisasi untuk nilai-nilai dependen tidak mungkin muncul sebagai TypeInitializationException.
  4. Kini API lebih mudah untuk diuji.

Manajemen kesalahan

Manajemen kesalahan dalam sistem besar merupakan upaya yang kompleks dan bernuansa, dan tidak ada solusi sederhana untuk memastikan bahwa sistem Anda toleran terhadap kesalahan dan berperilaku sebagaimana mestinya. Panduan berikut dapat memberikan petunjuk dalam menavigasi ruang yang sulit ini.

Merepresentasikan kasus kesalahan dan status ilegal dalam jenis intrinsik untuk domain Anda

Dengan Discriminated Unions, F# memberi Anda kemampuan untuk merepresentasikan status program yang rusak dalam sistem jenis Anda. Misalnya:

type MoneyWithdrawalResult =
    | Success of amount:decimal
    | InsufficientFunds of balance:decimal
    | CardExpired of DateTime
    | UndisclosedFailure

Dalam kasus ini, ada tiga cara yang diketahui di mana penarikan uang dari rekening bank bisa gagal. Setiap kasus kesalahan direpresentasikan dalam jenis, dan dengan demikian dapat ditangani secara aman di seluruh program.

let handleWithdrawal amount =
    let w = withdrawMoney amount
    match w with
    | Success am -> printfn $"Successfully withdrew %f{am}"
    | InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
    | CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
    | UndisclosedFailure -> printfn "Failed: unknown"

Secara umum, jika Anda dapat memodelkan berbagai cara di mana sesuatu dapat gagal di domain Anda, maka kode penanganan kesalahan tidak lagi diperlakukan sebagai sesuatu yang harus Anda tangani bersama dengan alur program reguler. Ini hanyalah bagian dari alur program normal, dan tidak dianggap pengecualian. Ada dua manfaat utama dari ini:

  1. Lebih mudah untuk mempertahankannya karena domain Anda berubah seiring waktu.
  2. Kasus kesalahan lebih mudah untuk diuji unit.

Menggunakan pengecualian ketika kesalahan tidak dapat direpresentasikan dengan jenis

Tidak semua kesalahan dapat direpresentasikan dalam domain masalah. Jenis kesalahan ini merupakan pengecualian, oleh karenanya ada kemampuan untuk memunculkan dan menangkap pengecualian di F#.

Pertama, Anda disarankan untuk membaca Pedoman Desain Pengecualian. Ini juga berlaku untuk F#.

Konstruksi utama yang tersedia di F# untuk tujuan memunculkan pengecualian harus dipertimbangkan dalam urutan preferensi berikut:

Function Sintaks Tujuan
nullArg nullArg "argumentName" Memunculkan System.ArgumentNullException dengan nama argumen yang ditentukan.
invalidArg invalidArg "argumentName" "message" Memunculkan System.ArgumentException dengan nama dan pesan argumen yang ditentukan.
invalidOp invalidOp "message" Memunculkan System.InvalidOperationException dengan pesan yang ditentukan.
raise raise (ExceptionType("message")) Mekanisme tujuan umum untuk menampilkan pengecualian.
failwith failwith "message" Memunculkan System.Exception dengan pesan yang ditentukan.
failwithf failwithf "format string" argForFormatString Memunculkan System.Exception dengan pesan yang ditentukan oleh format string dan inputnya.

Gunakan nullArg, invalidArg, dan invalidOp sebagai mekanisme untuk menampilkan ArgumentNullException, ArgumentException, dan InvalidOperationException bila sesuai.

Fungsi failwith dan failwithf umumnya harus dihindari karena fungsi ini memunculkan jenis Exception dasar, bukan pengecualian khusus. Sesuai dengan Pedoman Desain Pengecualian, Anda perlu memunculkan pengecualian yang lebih spesifik jika memungkinkan.

Menggunakan sintaks penanganan pengecualian

F# mendukung pola pengecualian melalui sintaks try...with:

try
    tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here

Merekonsiliasi fungsionalitas agar tetap bekerja bahkan saat menghadapi pengecualian dengan pencocokan pola bisa menjadi cukup sulit jika Anda ingin menjaga agar kode tetap bersih. Salah satu cara untuk menangani ini adalah menggunakan pola aktif sebagai sarana untuk mengelompokkan fungsionalitas di sekitar kasus kesalahan dengan pengecualian itu sendiri. Misalnya, Anda mungkin mengonsumsi API yang, saat menampilkan pengecualian, menyertakan informasi yang berguna dalam metadata pengecualian. Membuka nilai yang berguna di isi pengecualian yang diambil di dalam Pola Aktif dan mengembalikan nilai tersebut dapat membantu dalam beberapa situasi.

Jangan gunakan penanganan kesalahan monadik untuk mengganti pengecualian

Pengecualian sering dipandang sebagai tabu dalam paradigma fungsional murni. Memang, pengecualian melanggar kemurnian, jadi aman untuk menganggap mereka tidak murni secara fungsional. Namun, ini mengabaikan kenyataan di mana kode harus dijalankan, dan kesalahan runtime bahasa umum dapat terjadi. Secara umum, tulis kode tentang asumsi bahwa sebagian besar hal tidak murni atau total, untuk meminimalkan kejutan yang tidak menyenangkan (mirip dengan kosong catch di C# atau salah mengelola jejak tumpukan, membuang informasi).

Penting untuk mempertimbangkan kekuatan/aspek inti Pengecualian berikut sehubungan dengan relevansi dan kesesuaiannya dalam runtime bahasa umum .NET serta ekosistem lintas bahasa secara keseluruhan:

  • Mereka berisi informasi diagnostik mendetail, yang berguna saat men-debug masalah.
  • Mereka dipahami dengan baik oleh runtime bahasa umum dan bahasa .NET lainnya.
  • Mereka dapat mengurangi boilerplate secara signifikan jika dibandingkan dengan kode yang berusaha keras untuk menghindari pengecualian dengan menerapkan beberapa subset semantik secara ad-hoc.

Poin ketiga ini sangat penting. Untuk operasi kompleks nontrivial, gagal menggunakan pengecualian dapat menyebabkan berurusan dengan struktur seperti ini:

Result<Result<MyType, string>, string list>

Yang dapat dengan mudah menyebabkan kode rapuh seperti pencocokan pola pada kesalahan "diketik dengan kuat":

let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
    if e.Contains "Error string 1" then ...
    elif e.Contains "Error string 2" then ...
    else ... // Who knows?

Selain itu, memang menggiurkan untuk menelan pengecualian apa pun karena menginginkan fungsi "sederhana" yang mengembalikan jenis yang "lebih bagus":

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

Sayangnya, tryReadAllText dapat menampilkan banyak pengecualian berdasarkan banyak sekali hal yang dapat terjadi pada sistem file, dan kode ini membuang informasi apa pun tentang kemungkinan kesalahan yang sebenarnya di lingkungan Anda. Jika Anda mengganti kode ini dengan jenis hasil, maka Anda kembali ke penguraian pesan kesalahan "diketik dengan kuat":

// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Ok
    with e -> Error e.Message

let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
    if e.Contains "uh oh, here we go again..." then ...
    else ...

Dan menempatkan objek pengecualian itu sendiri di konstruktor Error hanya akan memaksa Anda untuk menangani jenis pengecualian dengan benar di situs panggilan, bukan di fungsi. Tindakan ini secara efektif menciptakan pengecualian yang diperiksa, yang terkenal tidak menyenangkan untuk ditangani sebagai pemanggil API.

Alternatif yang baik untuk contoh di atas adalah menangkap pengecualian tertentu dan mengembalikan nilai yang berarti dalam konteks pengecualian tersebut. Jika Anda memodifikasi fungsi tryReadAllText sebagai berikut, None memiliki arti lebih:

let tryReadAllTextIfPresent (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with :? FileNotFoundException -> None

Alih-alih berfungsi sebagai catch-all, kini fungsi ini akan menangani kasus dengan benar ketika file tidak ditemukan dan menetapkan makna tersebut ke tampilan. Nilai tampilan ini dapat dipetakan ke kasus kesalahan tersebut, tanpa membuang informasi kontekstual apa pun atau memaksa pemanggil untuk menangani kasus yang mungkin tidak relevan pada saat itu dalam kode.

Jenis seperti Result<'Success, 'Error> sesuai untuk operasi dasar di mana mereka tidak bersarang, dan jenis opsional F# sangat cocok untuk merepresentasikan ketika sesuatu dapat menampilkan sesuatu atau tidak ada sama sekali. Mereka bukan pengganti pengecualian, dan tidak boleh digunakan dalam upaya untuk mengganti pengecualian. Sebaliknya, mereka harus diterapkan secara bijaksana untuk menangani aspek-aspek khusus dari pengecualian dan kebijakan manajemen kesalahan dengan cara yang ditargetkan.

Aplikasi parsial dan pemrograman bebas titik

F# mendukung aplikasi parsial, dan dengan demikian, berbagai cara untuk melakukan pemrograman dalam gaya bebas titik. Ini dapat bermanfaat untuk penggunaan kembali kode dalam modul atau implementasi sesuatu, tetapi ini bukan sesuatu yang dapat diekspos secara publik. Secara umum, pemrograman bebas titik bukanlah hal yang benar-benar baik, dan dapat menambah hambatan kognitif yang signifikan bagi orang-orang yang tidak menguasai persoalan gaya.

Jangan gunakan aplikasi parsial dan kurir di API publik

Dengan sedikit pengecualian, penggunaan aplikasi parsial di API publik dapat membingungkan konsumen. Biasanya, nilai let-bound dalam kode F# adalah nilai, bukan nilai fungsi. Menggabungkan nilai dan nilai fungsi dapat menghasilkan penghematan beberapa baris kode sebagai ganti sedikit overhead kognitif, terutama jika dikombinasikan dengan operator seperti >> untuk menyusun fungsi.

Pertimbangkan implikasi alat untuk pemrograman bebas titik

Fungsi kurir tidak melabeli argumennya. Ini memiliki implikasi alat. Pertimbangkan dua fungsi berikut:

let func name age =
    printfn $"My name is {name} and I am %d{age} years old!"

let funcWithApplication =
    printfn "My name is %s and I am %d years old!"

Keduanya adalah fungsi yang valid, tetapi funcWithApplication adalah fungsi kurir. Saat Anda mengarahkan kursor ke jenisnya di editor, Anda melihat ini:

val func : name:string -> age:int -> unit

val funcWithApplication : (string -> int -> unit)

Di situs panggilan, tooltip dalam alat seperti Visual Studio akan memberi Anda tanda tangan jenis, tetapi karena tidak ada nama yang ditentukan, ini tidak akan menampilkan nama. Nama sangat penting untuk desain API yang baik karena membantu pemanggil lebih memahami arti di balik API. Menggunakan kode bebas titik di API publik dapat mempersulit pemanggil untuk memahaminya.

Jika Anda menemukan kode bebas titik seperti funcWithApplication yang dapat dikonsumsi publik, disarankan untuk melakukan ekspansi η penuh agar alat dapat mengambil nama yang bermakna untuk argumen.

Selain itu,penelusuran kesalahan kode bebas titik sangat menantang, atau bahkan tidak mungkin untuk dilakukan. Alat penelusuran kesalahan mengandalkan nilai yang terikat pada nama (misalnya, pengikatan let) sehingga Anda dapat memeriksa nilai perantara di tengah eksekusi. Ketika kode Anda tidak memiliki nilai untuk diperiksa, tidak ada yang perlu di-debug. Di masa mendatang, alat penelusuran kesalahan dapat berkembang untuk mensintesis nilai-nilai ini berdasarkan jalur yang dijalankan sebelumnya, tetapi bukanlah ide yang baik untuk mengandalkan potensi fungsi penelusuran kesalahan.

Pertimbangkan aplikasi parsial sebagai teknik untuk mengurangi boilerplate internal

Berbeda dengan poin sebelumnya, aplikasi parsial adalah alat yang luar biasa untuk mengurangi boilerplate di dalam aplikasi atau internal API yang lebih dalam. Ini dapat membantu untuk pengujian unit implementasi API yang lebih rumit, di mana boilerplate sering kali sulit untuk ditangani. Misalnya, kode berikut menunjukkan bagaimana Anda dapat mencapai apa yang diberikan oleh sebagian besar kerangka kerja tiruan tanpa mengambil dependensi eksternal pada kerangka kerja semacam itu dan mengharuskan untuk mempelajari API dipesan lebih dahulu yang terkait.

Misalnya, perhatikan topografi solusi berikut:

MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj

ImplementationLogic.fsproj mungkin mengekspos kode seperti:

module Transactions =
    let doTransaction txnContext txnType balance =
        ...

type Transactor(ctx, currentBalance) =
    member _.ExecuteTransaction(txnType) =
        Transactions.doTransaction ctx txnType currentBalance
        ...

Pengujian unit Transactions.doTransaction di ImplementationLogic.Tests.fsproj mudah dilakukan:

namespace TransactionsTestingUtil

open Transactions

module TransactionsTestable =
    let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext

Menerapkan sebagian doTransaction dengan objek konteks tiruan memungkinkan Anda memanggil fungsi di semua pengujian unit Anda tanpa perlu selalu membuat konteks tiruan:

module TransactionTests

open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable

let testableContext =
    { new ITransactionContext with
        member _.TheFirstMember() = ...
        member _.TheSecondMember() = ... }

let transactionRoutine = getTestableTransactionRoutine testableContext

[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
    let expected = ...
    let actual = transactionRoutine TransactionType.Withdraw 0.0
    Assert.Equal(expected, actual)

Jangan terapkan teknik ini secara universal ke seluruh basis kode Anda, tetapi ini adalah cara yang baik untuk mengurangi boilerplate untuk internal yang rumit dan menguji unit internal tersebut.

Kontrol akses

F# memiliki beberapa opsi untuk Kontrol akses, yang diwarisi dari apa yang tersedia di runtime bahasa umum .NET. Ini tidak hanya dapat digunakan untuk jenis - Anda juga dapat menggunakannya untuk fungsi.

Praktik yang baik dalam konteks pustaka yang banyak dikonsumsi:

  • Pilih jenis dan anggota yang bukan public sampai Anda membutuhkannya untuk dapat dikonsumsi oleh publik. Ini juga meminimalkan pasangan konsumen.
  • Berusaha untuk menjaga semua private fungsionalitas pembantu.
  • Pertimbangkan penggunaan [<AutoOpen>] pada modul privat fungsi pembantu jika jumlahnya banyak.

Inferensi dan generik jenis

Inferensi jenis dapat menyelamatkan Anda dari mengetik banyak boilerplate. Dan generalisasi otomatis dalam pengompilasi F# dapat membantu Anda menulis lebih banyak kode generik dengan hampir tanpa usaha ekstra dari pihak Anda. Namun, fitur-fitur ini tidak secara universal bagus.

  • Pertimbangkan untuk memberi label nama argumen dengan jenis eksplisit di API publik dan jangan mengandalkan inferensi jenis untuk ini.

    Alasannya adalah karena Anda, dan bukan pengompilasi, harus mengontrol bentuk API Anda. Meskipun pengompilasi dapat melakukan inferensi jenis dengan baik untuk Anda, bentuk API Anda dapat berubah jika internal yang diandalkannya telah mengubah jenis. Anda mungkin menginginkan ini, tetapi ini kemungkinan besar akan menghasilkan perubahan API yang melanggar yang kemudian harus dihadapi oleh konsumen hilir. Sebaliknya, jika Anda secara eksplisit mengontrol bentuk API publik Anda, maka Anda dapat mengontrol perubahan yang melanggar ini. Dalam istilah DDD, ini dapat dianggap sebagai lapisan Anti-korupsi.

  • Pertimbangkan untuk memberikan nama yang bermakna untuk argumen generik Anda.

    Kecuali Anda menulis kode yang benar-benar generik yang tidak spesifik untuk domain tertentu, nama yang bermakna dapat membantu pemrogram lain memahami domain tempat mereka bekerja. Misalnya, parameter jenis bernama 'Document dalam konteks interaksi dengan database dokumen memperjelas bahwa jenis dokumen generik dapat diterima oleh fungsi atau anggota yang sedang Anda kerjakan.

  • Pertimbangkan untuk menamai parameter jenis generik dengan PascalCase.

    Ini adalah cara umum untuk melakukan sesuatu di .NET, jadi sebaiknya Anda menggunakan PascalCase, dan bukan snake_case atau camelCase.

Terakhir, generalisasi otomatis tidak selalu menjadi keuntungan bagi orang yang baru mengenal F# atau basis kode yang besar. Ada overhead kognitif dalam menggunakan komponen yang generik. Selain itu, jika fungsi yang digeneralisasikan secara otomatis tidak digunakan dengan jenis input yang berbeda (apalagi jika memang dimaksudkan untuk digunakan seperti itu), maka tidak ada manfaat nyata dari menjadi generik. Selalu pertimbangkan apakah kode yang Anda tulis benar-benar akan mendapat manfaat dari menjadi generik.

Performa

Pertimbangkan struct untuk jenis kecil dengan tingkat alokasi tinggi

Menggunakan struct (juga disebut Jenis Nilai) sering kali dapat menghasilkan performa yang lebih tinggi untuk beberapa kode karena biasanya menghindari pengalokasian objek. Namun, struct tidak selalu menjadi tombol untuk "berjalan dengan lebih cepat": jika ukuran data dalam struct melebihi 16 byte, menyalin data sering kali dapat menghabiskan lebih banyak waktu CPU daripada jika menggunakan jenis referensi.

Untuk menentukan apakah Anda harus menggunakan struct, pertimbangkan kondisi berikut:

  • Jika ukuran data Anda 16 byte atau lebih kecil.
  • Jika Anda kemungkinan memiliki banyak instans jenis ini yang berada di memori dalam program yang sedang berjalan.

Jika kondisi pertama berlaku, Anda biasanya harus menggunakan struct. Jika keduanya berlaku, Anda hampir harus selalu menggunakan struct. Mungkin ada beberapa kasus di mana kondisi sebelumnya berlaku, tetapi menggunakan struct tidak lebih baik atau lebih buruk daripada menggunakan jenis referensi, tetapi kemungkinan itu jarang terjadi. Namun, penting untuk selalu melakukan pengukuran saat membuat perubahan seperti ini, dan tidak beroperasi berdasarkan asumsi atau intuisi.

Pertimbangkan tupel struct saat mengelompokkan jenis nilai kecil dengan tingkat alokasi tinggi

Pertimbangkan dua fungsi berikut:

let rec runWithTuple t offset times =
    let offsetValues x y z offset =
        (x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let (x, y, z) = t
        let r = offsetValues x y z offset
        runWithTuple r offset (times - 1)

let rec runWithStructTuple t offset times =
    let offsetValues x y z offset =
        struct(x + offset, y + offset, z + offset)

    if times <= 0 then
        t
    else
        let struct(x, y, z) = t
        let r = offsetValues x y z offset
        runWithStructTuple r offset (times - 1)

Saat Anda membandingkan fungsi ini dengan alat pembanding statistik seperti BenchmarkDotNet, Anda akan mendapati bahwa fungsi runWithStructTuple yang menggunakan tupel struct berjalan 40% lebih cepat dan tidak mengalokasikan memori.

Namun, hasil ini tidak akan selalu terjadi pada kode Anda sendiri. Jika Anda menandai suatu fungsi sebagai inline, kode yang menggunakan tupel referensi mungkin mendapatkan beberapa pengoptimalan tambahan, atau kode yang akan mengalokasikan dapat dengan mudah dioptimalkan. Anda harus selalu mengukur hasil setiap kali ada kaitannya dengan performa, dan jangan pernah beroperasi berdasarkan asumsi atau intuisi.

Pertimbangkan rekaman struct ketika jenisnya kecil dan memiliki tingkat alokasi yang tinggi

Aturan praktis yang dijelaskan sebelumnya juga berlaku untuk jenis rekaman F#. Pertimbangkan jenis data berikut dan fungsi yang memprosesnya:

type Point = { X: float; Y: float; Z: float }

[<Struct>]
type SPoint = { X: float; Y: float; Z: float }

let rec processPoint (p: Point) offset times =
    let inline offsetValues (p: Point) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processPoint r offset (times - 1)

let rec processStructPoint (p: SPoint) offset times =
    let inline offsetValues (p: SPoint) offset =
        { p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }

    if times <= 0 then
        p
    else
        let r = offsetValues p offset
        processStructPoint r offset (times - 1)

Ini mirip dengan kode tuple sebelumnya, tetapi kali ini contoh menggunakan rekaman dan fungsi dalam sebaris.

Saat Anda membandingkan fungsi ini dengan alat tolok ukur statistik seperti BenchmarkDotNet, Anda akan mendapati bahwa processStructPoint berjalan hampir 60% lebih cepat dan tidak mengalokasikan apa pun di heap terkelola.

Pertimbangkan discriminated unions struct ketika jenis data kecil dengan tingkat alokasi tinggi

Pengamatan sebelumnya tentang performa dengan tupel dan rekaman struct juga berlaku untuk Discriminated Unions F#. Pertimbangkan gambar berikut:

    type Name = Name of string

    [<Struct>]
    type SName = SName of string

    let reverseName (Name s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> Name

    let structReverseName (SName s) =
        s.ToCharArray()
        |> Array.rev
        |> System.String
        |> SName

Umum untuk mendefinisikan Discriminated Unions kasus tunggal seperti ini untuk pemodelan domain. Saat membandingkan fungsi ini dengan alat tolok ukur statistik seperti BenchmarkDotNet, Anda akan mendapati bahwa structReverseName berjalan sekitar 25% lebih cepat daripada reverseName untuk string kecil. Untuk string besar, keduanya memiliki performa yang hampir sama. Jadi, dalam kasus ini, selalu lebih baik menggunakan struct. Seperti disebutkan sebelumnya, selalu lakukan pengukuran dan jangan beroperasi berdasarkan asumsi atau intuisi.

Meskipun contoh sebelumnya menunjukkan bahwa struct Discriminated Union menghasilkan performa yang lebih baik, umum untuk memiliki Discriminated Union yang lebih besar saat memodelkan domain. Jenis data yang lebih besar seperti itu mungkin tidak berfungsi dengan baik jika mereka adalah struct, tergantung pada operasinya, karena dapat melibatkan lebih banyak penyalinan.

Kekekalan dan mutasi

Nilai F# tidak dapat diubah secara default, yang memungkinkan Anda untuk menghindari kelas bug tertentu (terutama yang melibatkan konkurensi dan paralelisme). Namun, dalam kasus tertentu, untuk mencapai efisiensi waktu eksekusi atau alokasi memori yang optimal (atau bahkan masuk akal), rentang kerja paling baik diterapkan menggunakan mutasi status di tempat. Ini dimungkinkan dalam basis keikutsertaan dengan F# dengan kata kunci mutable.

Penggunaan mutable di F# mungkin terasa bertentangan dengan kemurnian fungsional. Ini dapat dimengerti, tetapi kemurnian fungsional di mana pun dapat bertentangan dengan tujuan performa. Jalan tengahnya adalah mengenkapsulasi mutasi sedemikian rupa sehingga pemanggil tidak perlu peduli tentang apa yang terjadi ketika mereka memanggil suatu fungsi. Ini memungkinkan Anda untuk menulis antarmuka fungsional melalui implementasi berbasis mutasi untuk kode yang penting bagi performa.

Selain itu, konstruksi pengikatan F# let memungkinkan Anda untuk menyarangkan pengikatan ke yang lain, ini dapat dimanfaatkan untuk menjaga cakupan mutable variabel tetap dekat atau pada terkecil teoritiknya.

let data =
    [
        let mutable completed = false
        while not completed do
            logic ()
            // ...
            if someCondition then
                completed <- true   
    ]

Tidak ada kode yang dapat mengakses yang dapat completed diubah yang hanya digunakan untuk menginisialisasi data biarkan nilai terikat.

Bungkus kode yang dapat berubah dalam antarmuka yang tidak dapat diubah

Dengan transparansi referensial sebagai tujuan, sangat penting untuk menulis kode yang tidak mengekspos kelemahan yang dapat berubah dari fungsi yang penting bagi performa. Misalnya, kode berikut mengimplementasikan fungsi Array.contains di pustaka inti F#:

[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0
    while not state && i < array.Length do
        state <- value = array[i]
        i <- i + 1
    state

Memanggil fungsi ini beberapa kali tidak mengubah array yang mendasarinya, juga tidak mengharuskan Anda untuk mempertahankan status yang dapat diubah dalam mengonsumsinya. Ini transparan secara referensial, meskipun hampir setiap baris kode di dalamnya menggunakan mutasi.

Pertimbangkan untuk mengenkapsulasi data yang bisa berubah dalam kelas

Contoh sebelumnya menggunakan fungsi tunggal untuk mengenkapsulasi operasi menggunakan data yang dapat diubah. Ini tidak selalu cukup untuk kumpulan data yang lebih kompleks. Perhatikan set fungsi berikut:

open System.Collections.Generic

let addToClosureTable (key, value) (t: Dictionary<_,_>) =
    if t.ContainsKey(key) then
        t[key] <- value
    else
        t.Add(key, value)

let closureTableCount (t: Dictionary<_,_>) = t.Count

let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
    match t.TryGetValue(key) with
    | (true, v) -> v.Equals(value)
    | (false, _) -> false

Kode ini berperforma baik, tetapi memperlihatkan struktur data berbasis mutasi yang harus dipelihara oleh pemanggil. Ini dapat dibungkus di dalam kelas tanpa anggota yang mendasarinya yang dapat berubah:

open System.Collections.Generic

/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
    let t = Dictionary<Item0, HashSet<TerminalIndex>>()

    member _.Add(key, value) =
        if t.ContainsKey(key) then
            t[key] <- value
        else
            t.Add(key, value)

    member _.Count = t.Count

    member _.Contains(key, value) =
        match t.TryGetValue(key) with
        | (true, v) -> v.Equals(value)
        | (false, _) -> false

Closure1Table mengenkapsulasi struktur data berbasis mutasi yang mendasarinya, sehingga tidak memaksa pemanggil untuk mempertahankan struktur data yang mendasarinya. Kelas adalah cara yang ampuh untuk mengenkapsulasi data dan rutinitas yang berbasis mutasi tanpa mengekspos detailnya kepada pemanggil.

Lebih suka let mutableref

Sel referensi adalah cara untuk merepresentasikan referensi ke nilai, alih-alih nilai itu sendiri. Meskipun mereka dapat digunakan untuk kode yang penting bagi performa, mereka tidak direkomendasikan. Pertimbangkan contoh berikut:

let kernels =
    let acc = ref Set.empty

    processWorkList startKernels (fun kernel ->
        if not ((!acc).Contains(kernel)) then
            acc := (!acc).Add(kernel)
        ...)

    !acc |> Seq.toList

Penggunaan sel referensi sekarang "mencemari" semua kode berikutnya dengan keharusan melakukan dereferensi dan referensi ulang data yang mendasarinya. Sebagai gantinya, pertimbangkan let mutable:

let kernels =
    let mutable acc = Set.empty

    processWorkList startKernels (fun kernel ->
        if not (acc.Contains(kernel)) then
            acc <- acc.Add(kernel)
        ...)

    acc |> Seq.toList

Selain dari satu titik mutasi di tengah ekspresi lambda, semua kode lain yang menyentuh acc dapat melakukannya dengan cara yang tidak berbeda dengan penggunaan nilai let -bound normal yang tidak dapat diubah. Ini akan membuatnya lebih mudah untuk berubah seiring waktu.

Nilai null dan default

Null umumnya harus dihindari di F#. Secara default, jenis yang dinyatakan F#tidak mendukung penggunaan null literal, dan semua nilai dan objek diinisialisasi. Namun, beberapa API .NET umum mengembalikan atau menerima null, dan beberapa umum . Jenis yang dideklarasikan NET seperti array dan string memungkinkan null. Namun, terjadinya null nilai sangat jarang terjadi dalam pemrograman F# dan salah satu manfaat menggunakan F# adalah untuk menghindari kesalahan referensi null dalam banyak kasus.

Hindari penggunaan AllowNullLiteral atribut

Secara default, jenis yang dinyatakan F#tidak mendukung penggunaan null literal. Anda dapat membuat anotasi jenis F# secara manual dengan AllowNullLiteral untuk mengizinkan ini. Namun, hampir selalu lebih baik untuk menghindari hal ini.

Hindari penggunaan Unchecked.defaultof<_> atribut

Dimungkinkan untuk menghasilkan null nilai atau nol-inisialisasi untuk jenis F# dengan menggunakan Unchecked.defaultof<_>. Ini dapat berguna saat menginisialisasi penyimpanan untuk beberapa struktur data, atau dalam beberapa pola pengkodian berperforma tinggi, atau dalam interoperabilitas. Namun penggunaan konstruksi ini harus dihindari.

Hindari penggunaan DefaultValue atribut

Secara default, rekaman dan objek F# harus diinisialisasi dengan benar pada konstruksi. Atribut DefaultValue dapat digunakan untuk mengisi beberapa bidang objek dengan null nilai atau nol yang diinisialisasi. Konstruksi ini jarang diperlukan dan penggunaannya harus dihindari.

Jika Anda memeriksa input null, ajukan pengecualian pada kesempatan pertama

Saat menulis kode F# baru, dalam praktiknya tidak perlu memeriksa input null, kecuali Anda mengharapkan kode tersebut digunakan dari C# atau bahasa .NET lainnya.

Jika Anda memutuskan untuk menambahkan pemeriksaan input null, lakukan pemeriksaan pada kesempatan pertama dan ajukan pengecualian. Misalnya:

let inline checkNonNull argName arg =
    if isNull arg then
        nullArg argName

module Array =
    let contains value (array:'T[]) =
        checkNonNull "array" array
        let mutable result = false
        let mutable i = 0
        while not state && i < array.Length do
            result <- value = array[i]
            i <- i + 1
        result

Untuk alasan warisan beberapa fungsi string di FSharp.Core masih memperlakukan null sebagai string kosong dan tidak gagal pada argumen null. Namun jangan menganggap ini sebagai panduan, dan jangan mengadopsi pola pengkodian yang mengaitkan arti semantik apa pun dengan "null".

Pemrograman objek

F# memiliki dukungan penuh untuk objek dan konsep berorientasi objek (OO). Meskipun banyak konsep OO yang canggih dan berguna, tidak semuanya ideal untuk digunakan. Daftar berikut menawarkan panduan tentang kategori fitur OO pada tingkat tinggi.

Pertimbangkan untuk menggunakan fitur ini dalam banyak situasi:

  • Notasi titik (x.Length)
  • Anggota instans
  • Konstruktor implisit
  • Anggota statis
  • Notasi pengindeks (arr[x]), dengan mendefinisikan properti Item
  • Notasi irisan (arr[x..y], arr[x..], arr[..y]), dengan mendefinisikan anggota GetSlice
  • Argumen Bernama dan Opsional
  • Antarmuka dan implementasi antarmuka

Jangan menggunakan fitur-fitur ini terlebih dahulu, tetapi terapkan dengan bijaksana ketika fitur ini sesuai untuk memecahkan masalah:

  • Metode kelebihan beban
  • Data yang dapat diubah yang dienkapsulasi
  • Operator pada jenis
  • Properti otomatis
  • Menerapkan IDisposable dan IEnumerable
  • Ekstensi jenis
  • Acara
  • Struct
  • Delegasikan
  • Enum

Umumnya hindari fitur ini kecuali jika Anda harus menggunakannya:

  • Hierarki jenis berbasis pewarisan dan pewarisan implementasi
  • Null dan Unchecked.defaultof<_>

Komposisi lebih disukai daripada pewarisan

Komposisi atas pewarisan adalah idiom lama yang dapat dipatuhi oleh kode F# yang baik. Prinsip dasarnya adalah Anda tidak boleh mengekspos kelas dasar dan memaksa pemanggil untuk mewarisi dari kelas dasar tersebut untuk mendapatkan fungsionalitas.

Gunakan ekspresi objek untuk mengimplementasikan antarmuka jika Anda tidak membutuhkan kelas

Ekspresi Objek memungkinkan Anda untuk mengimplementasikan antarmuka dengan cepat, mengikat antarmuka yang diimplementasikan ke nilai tanpa perlu melakukannya di dalam kelas. Ini tidak menyusahkan, terutama jika Anda hanya perlu mengimplementasikan antarmuka dan tidak membutuhkan kelas penuh.

Misalnya, berikut adalah kode yang dijalankan di Ionide untuk memberikan tindakan perbaikan kode jika Anda telah menambahkan simbol yang tidak ada pernyataan open-nya:

    let private createProvider () =
        { new CodeActionProvider with
            member this.provideCodeActions(doc, range, context, ct) =
                let diagnostics = context.diagnostics
                let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
                let res =
                    match diagnostic with
                    | None -> [||]
                    | Some d ->
                        let line = doc.lineAt d.range.start.line
                        let cmd = createEmpty<Command>
                        cmd.title <- "Remove unused open"
                        cmd.command <- "fsharp.unusedOpenFix"
                        cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
                        [|cmd |]
                res
                |> ResizeArray
                |> U2.Case1
        }

Karena tidak diperlukan kelas saat berinteraksi dengan Visual Studio Code API, Ekspresi Objek adalah alat yang ideal untuk ini. Ekspresi Objek juga bermanfaat untuk pengujian unit, ketika Anda ingin mematikan antarmuka dengan rutinitas pengujian dengan cara yang diimprovisasi.

Pertimbangkan Singkatan Jenis untuk mempersingkat tanda tangan

Singkatan Jenis adalah cara mudah untuk menetapkan label ke jenis lain, seperti tanda tangan fungsi atau jenis yang lebih kompleks. Misalnya, alias berikut menetapkan label pada apa yang diperlukan untuk mendefinisikan komputasi dengan CNTK, sebuah pustaka deep learning:

open CNTK

// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function

Nama Computation adalah cara yang mudah untuk menunjukkan fungsi apa pun yang cocok dengan tanda tangan aliasnya. Menggunakan Singkatan Jenis seperti ini nyaman dan memungkinkan kode yang lebih ringkas.

Hindari menggunakan Singkatan Jenis untuk mewakili domain Anda

Meskipun Singkatan Jenis adalah cara yang mudah untuk memberi nama pada tanda tangan fungsi, Singkatan Jenis dapat membingungkan saat menyingkat jenis lain. Pertimbangkan singkatan ini:

// Does not actually abstract integers.
type BufferSize = int

Ini dapat membingungkan dalam berbagai cara:

  • BufferSize bukan abstraksi; itu hanyalah nama lain untuk bilangan bulat.
  • Jika BufferSize diekspos di API publik, ini dapat dengan mudah disalahartikan sebagai lebih dari sekadar int. Umumnya, jenis domain memiliki banyak atribut dan bukan jenis primitif seperti int. Singkatan ini melanggar asumsi tersebut.
  • Casing BufferSize (PascalCase) menyiratkan bahwa jenis ini menyimpan lebih banyak data.
  • Alias ini tidak menawarkan peningkatan kejelasan dibandingkan dengan memberikan argumen bernama ke suatu fungsi.
  • Singkatan tidak akan muncul dalam bahasa perantara yang dikompilasi; itu hanya bilangan bulat dan alias ini adalah konstruksi waktu kompilasi.
module Networking =
    ...
    let send data (bufferSize: int) = ...

Singkatnya, jebakan yang ada di Singkatan Jenis adalah bahwa mereka bukan abstraksi atas jenis yang mereka singkat. Pada contoh sebelumnya, BufferSize hanyalah int di baliknya, tanpa data tambahan, atau manfaat apa pun dari sistem jenis selain yang sudah dimiliki int.

Pendekatan alternatif untuk menggunakan singkatan jenis untuk mewakili domain adalah menggunakan discriminated unions kasus tunggal. Contoh sebelumnya dapat dimodelkan sebagai berikut:

type BufferSize = BufferSize of int

Jika Anda menulis kode yang beroperasi dalam hal BufferSize dan nilai dasarnya, Anda perlu membuatnya, dan bukan meneruskan bilangan bulat arbitrer:

module Networking =
    ...
    let send data (BufferSize size) =
    ...

Ini mengurangi kemungkinan salah meneruskan bilangan bulat arbitrer ke dalam fungsi send, karena pemanggil harus membuat jenis BufferSize untuk membungkus nilai sebelum memanggil fungsi.