F# 教程

您所在的位置:网站首页 fable是什么意思翻译 F# 教程

F# 教程

2024-01-22 01:20| 来源: 网络整理| 查看: 265

F# 教程 项目 05/10/2023

了解 F# 的最佳方法就是阅读和编写 F# 代码。 本文将介绍 F# 的一些关键功能,并提供一些可在计算机上执行的代码片段。 若要了解如何设置开发环境,请查看入门。

F# 中有两个主要概念:函数和类型。 本教程重点介绍该语言在两个概念方面的功能。

联机执行代码

如果你的计算机上未安装 F#,可以使用 在 Fable 中试用 F# 在浏览器中执行所有示例。 Fable 是 F# 的一种方言,可直接在浏览器中执行。 若要按照 REPL 查看示例,请参考 Fable REPL 左侧菜单栏中的“示例”>“学习”>“F# 教程”。

函数和模块

任何 F# 程序最基本的部分都是组织成模块的函数。 函数对输入执行工作以生成输出,它们组织在模块下,这是 F# 中对内容进行分组的主要方式。 它们使用 let 绑定来定义,该绑定为函数提供一个名称并定义其参数。

module BasicFunctions = /// You use 'let' to define a function. This one accepts an integer argument and returns an integer. /// Parentheses are optional for function arguments, except for when you use an explicit type annotation. let sampleFunction1 x = x*x + 3 /// Apply the function, naming the function return result using 'let'. /// The variable type is inferred from the function return type. let result1 = sampleFunction1 4573 // This line uses '%d' to print the result as an integer. This is type-safe. // If 'result1' were not of type 'int', then the line would fail to compile. printfn $"The result of squaring the integer 4573 and adding 3 is %d{result1}" /// When needed, annotate the type of a parameter name using '(argument:type)'. Parentheses are required. let sampleFunction2 (x:int) = 2*x*x - x/5 + 3 let result2 = sampleFunction2 (7 + 4) printfn $"The result of applying the 2nd sample function to (7 + 4) is %d{result2}" /// Conditionals use if/then/elif/else. /// /// Note that F# uses white space indentation-aware syntax, similar to languages like Python. let sampleFunction3 x = if x < 100.0 then 2.0*x*x - x/5.0 + 3.0 else 2.0*x*x + x/5.0 - 37.0 let result3 = sampleFunction3 (6.5 + 4.5) // This line uses '%f' to print the result as a float. As with '%d' above, this is type-safe. printfn $"The result of applying the 3rd sample function to (6.5 + 4.5) is %f{result3}"

let 绑定也是将值绑定到名称的方式,类似于其他语言中的变量。 let 绑定在默认情况下是不可变的,这意味着一旦值或函数绑定到名称,就无法更改。 这与其他语言中的变量相反,变量是可变的,这意味着它们的值可以随时更改。 如果需要可变绑定,可以使用 let mutable ... 语法。

module Immutability = /// Binding a value to a name via 'let' makes it immutable. /// /// The second line of code compiles, but 'number' from that point onward will shadow the previous definition. /// There is no way to access the previous definition of 'number' due to shadowing. let number = 2 // let number = 3 /// A mutable binding. This is required to be able to mutate the value of 'otherNumber'. let mutable otherNumber = 2 printfn $"'otherNumber' is {otherNumber}" // When mutating a value, use ' List.filter isOdd |> List.map square |> List.map addOne printfn $"processing {numbers} through 'squareOddValuesAndAddOnePipeline' produces: {squareOddValuesAndAddOnePipeline numbers}" /// You can shorten 'squareOddValuesAndAddOnePipeline' by moving the second `List.map` call /// into the first, using a Lambda Function. /// /// Note that pipelines are also being used inside the lambda function. F# pipe operators /// can be used for single values as well. This makes them very powerful for processing data. let squareOddValuesAndAddOneShorterPipeline values = values |> List.filter isOdd |> List.map(fun x -> x |> square |> addOne) printfn $"processing {numbers} through 'squareOddValuesAndAddOneShorterPipeline' produces: {squareOddValuesAndAddOneShorterPipeline numbers}" /// Lastly, you can eliminate the need to explicitly take 'values' in as a parameter by using '>>' /// to compose the two core operations: filtering out even numbers, then squaring and adding one. /// Likewise, the 'fun x -> ...' bit of the lambda expression is also not needed, because 'x' is simply /// being defined in that scope so that it can be passed to a functional pipeline. Thus, '>>' can be used /// there as well. /// /// The result of 'squareOddValuesAndAddOneComposition' is itself another function which takes a /// list of integers as its input. If you execute 'squareOddValuesAndAddOneComposition' with a list /// of integers, you'll notice that it produces the same results as previous functions. /// /// This is using what is known as function composition. This is possible because functions in F# /// use Partial Application and the input and output types of each data processing operation match /// the signatures of the functions we're using. let squareOddValuesAndAddOneComposition = List.filter isOdd >> List.map (square >> addOne) printfn $"processing {numbers} through 'squareOddValuesAndAddOneComposition' produces: {squareOddValuesAndAddOneComposition numbers}"

上一示例使用了 F# 的许多功能,包括列表处理函数、一级函数和部分应用。 尽管这些都是高级概念,但应该清楚地了解在生成管道时如何使用函数处理数据。

列表、数组和序列

列表、数组和序列是 F# 核心库中的三个主要集合类型。

列表是相同类型的元素的有序不可变集合。 它们是单一链接列表,这意味着它们主要用于枚举,但如果规模较大,则对于随机访问和串联来说,这并不是一个好的选择。 这与其他常用语言中的列表相反,这些语言通常不使用单一链接列表来表示列表。

module Lists = /// Lists are defined using [ ... ]. This is an empty list. let list1 = [ ] /// This is a list with 3 elements. ';' is used to separate elements on the same line. let list2 = [ 1; 2; 3 ] /// You can also separate elements by placing them on their own lines. let list3 = [ 1 2 3 ] /// This is a list of integers from 1 to 1000 let numberList = [ 1 .. 1000 ] /// Lists can also be generated by computations. This is a list containing /// all the days of the year. /// /// 'yield' is used for on-demand evaluation. More on this later in Sequences. let daysList = [ for month in 1 .. 12 do for day in 1 .. System.DateTime.DaysInMonth(2017, month) do yield System.DateTime(2017, month, day) ] // Print the first 5 elements of 'daysList' using 'List.take'. printfn $"The first 5 days of 2017 are: {daysList |> List.take 5}" /// Computations can include conditionals. This is a list containing the tuples /// which are the coordinates of the black squares on a chess board. let blackSquares = [ for i in 0 .. 7 do for j in 0 .. 7 do if (i+j) % 2 = 1 then yield (i, j) ] /// Lists can be transformed using 'List.map' and other functional programming combinators. /// This definition produces a new list by squaring the numbers in numberList, using the pipeline /// operator to pass an argument to List.map. let squares = numberList |> List.map (fun x -> x*x) /// There are many other list combinations. The following computes the sum of the squares of the /// numbers divisible by 3. let sumOfSquares = numberList |> List.filter (fun x -> x % 3 = 0) |> List.sumBy (fun x -> x * x) printfn $"The sum of the squares of numbers up to 1000 that are divisible by 3 is: %d{sumOfSquares}"

数组是相同类型的元素的大小固定、可变集合。 它们支持元素的快速随机访问,并且比 F# 列表更快,因为它们只是连续的内存块。

module Arrays = /// This is The empty array. Note that the syntax is similar to that of Lists, but uses `[| ... |]` instead. let array1 = [| |] /// Arrays are specified using the same range of constructs as lists. let array2 = [| "hello"; "world"; "and"; "hello"; "world"; "again" |] /// This is an array of numbers from 1 to 1000. let array3 = [| 1 .. 1000 |] /// This is an array containing only the words "hello" and "world". let array4 = [| for word in array2 do if word.Contains("l") then yield word |] /// This is an array initialized by index and containing the even numbers from 0 to 2000. let evenNumbers = Array.init 1001 (fun n -> n * 2) /// Sub-arrays are extracted using slicing notation. let evenNumbersSlice = evenNumbers[0..500] /// You can loop over arrays and lists using 'for' loops. for word in array4 do printfn $"word: {word}" // You can modify the contents of an array element by using the left arrow assignment operator. // // To learn more about this operator, see: https://learn.microsoft.com/dotnet/fsharp/language-reference/values/index#mutable-variables array2[1] Array.filter (fun x -> x.StartsWith "h") |> Array.sumBy (fun x -> x.Length) printfn $"The sum of the lengths of the words in Array 2 is: %d{sumOfLengthsOfWords}"

序列是相同类型的元素的逻辑序列。 这些类型比列表和数组更常规,能够作为任何逻辑元素序列的“视图”。 它们还非常特别,因为它们可能很“懒”,这意味着只有在需要元素时才会计算元素。

module Sequences = /// This is the empty sequence. let seq1 = Seq.empty /// This a sequence of values. let seq2 = seq { yield "hello"; yield "world"; yield "and"; yield "hello"; yield "world"; yield "again" } /// This is an on-demand sequence from 1 to 1000. let numbersSeq = seq { 1 .. 1000 } /// This is a sequence producing the words "hello" and "world" let seq3 = seq { for word in seq2 do if word.Contains("l") then yield word } /// This is a sequence producing the even numbers up to 2000. let evenNumbers = Seq.init 1001 (fun n -> n * 2) let rnd = System.Random() /// This is an infinite sequence which is a random walk. /// This example uses yield! to return each element of a subsequence. let rec randomWalk x = seq { yield x yield! randomWalk (x + rnd.NextDouble() - 0.5) } /// This example shows the first 100 elements of the random walk. let first100ValuesOfRandomWalk = randomWalk 5.0 |> Seq.truncate 100 |> Seq.toList printfn $"First 100 elements of a random walk: {first100ValuesOfRandomWalk}" 递归函数

处理元素的集合或序列通常是通过 F# 中的递归来完成的。 尽管 F# 支持循环和命令性编程,但首选递归,因为它更容易保证正确性。

注意

下面的示例通过 match 表达式来使用模式匹配。 本文稍后将介绍此基本构造。

module RecursiveFunctions = /// This example shows a recursive function that computes the factorial of an /// integer. It uses 'let rec' to define a recursive function. let rec factorial n = if n = 0 then 1 else n * factorial (n-1) printfn $"Factorial of 6 is: %d{factorial 6}" /// Computes the greatest common factor of two integers. /// /// Since all of the recursive calls are tail calls, /// the compiler will turn the function into a loop, /// which improves performance and reduces memory consumption. let rec greatestCommonFactor a b = if a = 0 then b elif a < b then greatestCommonFactor a (b - a) else greatestCommonFactor (a - b) b printfn $"The Greatest Common Factor of 300 and 620 is %d{greatestCommonFactor 300 620}" /// This example computes the sum of a list of integers using recursion. /// /// '::' is used to split a list into the head and tail of the list, /// the head being the first element and the tail being the rest of the list. let rec sumList xs = match xs with | [] -> 0 | y::ys -> y + sumList ys /// This makes 'sumList' tail recursive, using a helper function with a result accumulator. let rec private sumListTailRecHelper accumulator xs = match xs with | [] -> accumulator | y::ys -> sumListTailRecHelper (accumulator+y) ys /// This invokes the tail recursive helper function, providing '0' as a seed accumulator. /// An approach like this is common in F#. let sumListTailRecursive xs = sumListTailRecHelper 0 xs let oneThroughTen = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10] printfn $"The sum 1-10 is %d{sumListTailRecursive oneThroughTen}"

F# 还完全支持尾调用优化,这是优化递归调用的一种方法,以便它们的速度与循环构造一样快。

记录和可区分联合类型

记录和联合类型是 F# 代码中使用的两种基本数据类型,通常是在 F# 程序中表示数据的最佳方法。 尽管这使得它们类似于其他语言中的类,但主要区别之一是它们具有结构相等语义。 这意味着它们具有“本机”可比性且相等性非常简单 - 只需检查一个是否等于另一个。

记录是已命名值的聚合,具有可选成员(如方法)。 如果你熟悉 C# 或 Java,则它们应类似于 POCO 或 POJO - 只是具有结构相等性并且更加简单。

module RecordTypes = /// This example shows how to define a new record type. type ContactCard = { Name : string Phone : string Verified : bool } /// This example shows how to instantiate a record type. let contact1 = { Name = "Alf" Phone = "(206) 555-0157" Verified = false } /// You can also do this on the same line with ';' separators. let contactOnSameLine = { Name = "Alf"; Phone = "(206) 555-0157"; Verified = false } /// This example shows how to use "copy-and-update" on record values. It creates /// a new record value that is a copy of contact1, but has different values for /// the 'Phone' and 'Verified' fields. /// /// To learn more, see: https://learn.microsoft.com/dotnet/fsharp/language-reference/copy-and-update-record-expressions let contact2 = { contact1 with Phone = "(206) 555-0112" Verified = true } /// This example shows how to write a function that processes a record value. /// It converts a 'ContactCard' object to a string. let showContactCard (c: ContactCard) = c.Name + " Phone: " + c.Phone + (if not c.Verified then " (unverified)" else "") printfn $"Alf's Contact Card: {showContactCard contact1}" /// This is an example of a Record with a member. type ContactCardAlternate = { Name : string Phone : string Address : string Verified : bool } /// Members can implement object-oriented members. member this.PrintedContactCard = this.Name + " Phone: " + this.Phone + (if not this.Verified then " (unverified)" else "") + this.Address let contactAlternate = { Name = "Alf" Phone = "(206) 555-0157" Verified = false Address = "111 Alf Street" } // Members are accessed via the '.' operator on an instantiated type. printfn $"Alf's alternate contact card is {contactAlternate.PrintedContactCard}"

你还可以将记录表示为结构。 这通过 [] 属性来完成:

[] type ContactCardStruct = { Name : string Phone : string Verified : bool }

可区分联合 (DU) 是可以是许多已命名窗体或用例的值。 该类型中存储的数据可以为多个不同值之一。

module DiscriminatedUnions = /// The following represents the suit of a playing card. type Suit = | Hearts | Clubs | Diamonds | Spades /// A Discriminated Union can also be used to represent the rank of a playing card. type Rank = /// Represents the rank of cards 2 .. 10 | Value of int | Ace | King | Queen | Jack /// Discriminated Unions can also implement object-oriented members. static member GetAllRanks() = [ yield Ace for i in 2 .. 10 do yield Value i yield Jack yield Queen yield King ] /// This is a record type that combines a Suit and a Rank. /// It's common to use both Records and Discriminated Unions when representing data. type Card = { Suit: Suit; Rank: Rank } /// This computes a list representing all the cards in the deck. let fullDeck = [ for suit in [ Hearts; Diamonds; Clubs; Spades] do for rank in Rank.GetAllRanks() do yield { Suit=suit; Rank=rank } ] /// This example converts a 'Card' object to a string. let showPlayingCard (c: Card) = let rankString = match c.Rank with | Ace -> "Ace" | King -> "King" | Queen -> "Queen" | Jack -> "Jack" | Value n -> string n let suitString = match c.Suit with | Clubs -> "clubs" | Diamonds -> "diamonds" | Spades -> "spades" | Hearts -> "hearts" rankString + " of " + suitString /// This example prints all the cards in a playing deck. let printAllCards() = for card in fullDeck do printfn $"{showPlayingCard card}"

还可以将 DU 用作“单一大小写可区分联合”,以帮助对基元类型进行域建模。 通常,字符串和其他基元类型用于表示某些内容,因此具有特定的含义。 但是,仅使用数据的基元表示形式可能会导致错误地分配不正确的值! 在此方案中,将每种类型的信息表示为不同的单一大小写联合可以强制实施正确性。

// Single-case DUs are often used for domain modeling. This can buy you extra type safety // over primitive types such as strings and ints. // // Single-case DUs cannot be implicitly converted to or from the type they wrap. // For example, a function which takes in an Address cannot accept a string as that input, // or vice versa. type Address = Address of string type Name = Name of string type SSN = SSN of int // You can easily instantiate a single-case DU as follows. let address = Address "111 Alf Way" let name = Name "Alf" let ssn = SSN 1234567890 /// When you need the value, you can unwrap the underlying value with a simple function. let unwrapAddress (Address a) = a let unwrapName (Name n) = n let unwrapSSN (SSN s) = s // Printing single-case DUs is simple with unwrapping functions. printfn $"Address: {address |> unwrapAddress}, Name: {name |> unwrapName}, and SSN: {ssn |> unwrapSSN}"

如上面的示例所示,若要获取单一大小写可区分联合中的基础值,必须将其显式展开。

此外,DU 还支持递归定义,让你可以轻松表示树和固有递归数据。 例如,下面演示了如何使用 exists 和 insert 函数表示二进制文件搜索树。

/// Discriminated Unions also support recursive definitions. /// /// This represents a Binary Search Tree, with one case being the Empty tree, /// and the other being a Node with a value and two subtrees. /// /// Note 'T here is a type parameter, indicating that 'BST' is a generic type. /// More on generics later. type BST /// Check if an item exists in the binary search tree. /// Searches recursively using Pattern Matching. Returns true if it exists; otherwise, false. let rec exists item bst = match bst with | Empty -> false | Node (x, left, right) -> if item = x then true elif item < x then (exists item left) // Check the left subtree. else (exists item right) // Check the right subtree. /// Inserts an item in the Binary Search Tree. /// Finds the place to insert recursively using Pattern Matching, then inserts a new node. /// If the item is already present, it does not insert anything. let rec insert item bst = match bst with | Empty -> Node(item, Empty, Empty) | Node(x, left, right) as node -> if item = x then node // No need to insert, it already exists; return the node. elif item < x then Node(x, insert item left, right) // Call into left subtree. else Node(x, left, insert item right) // Call into right subtree.

由于 DU 允许以数据类型表示树的递归结构,因此对此递归结构的操作非常简单,并且可保证正确性。 它在模式匹配中也受支持,如下所示。

模式匹配

模式匹配是一项 F# 功能,可确保操作 F# 类型的正确性。 在以上示例中,你可能会注意到有很多 match x with ... 语法。 此构造允许编译器(可以理解数据类型的“形状”)强制你在通过所谓的“详尽模式匹配”使用数据类型时考虑所有可能的情况。 这对于保证正确性来说非常强大,并且可以智能地用于通常的运行时问题“提升”为编译时问题。

module PatternMatching = /// A record for a person's first and last name type Person = { First : string Last : string } /// A Discriminated Union of 3 different kinds of employees type Employee = | Engineer of engineer: Person | Manager of manager: Person * reports: List | Executive of executive: Person * reports: List * assistant: Employee /// Count everyone underneath the employee in the management hierarchy, /// including the employee. The matches bind names to the properties /// of the cases so that those names can be used inside the match branches. /// Note that the names used for binding do not need to be the same as the /// names given in the DU definition above. let rec countReports(emp : Employee) = 1 + match emp with | Engineer(person) -> 0 | Manager(person, reports) -> reports |> List.sumBy countReports | Executive(person, reports, assistant) -> (reports |> List.sumBy countReports) + countReports assistant

你可能注意到使用了 _ 模式。 这称为通配符模式,这是一种“我不关心是什么”的模式。 尽管很方便,但如果不谨慎使用 _,可能会意外绕过“详尽模式匹配”,并且不再受益于编译时强制实施。 如果在模式匹配时不关心分解类型的某些部分,或者当在模式匹配表达式中枚举所有有意义的事例时,最好使用最终子句。

在下面的示例中,当分析操作失败时,将使用 _ 案例。

/// Find all managers/executives named "Dave" who do not have any reports. /// This uses the 'function' shorthand to as a lambda expression. let findDaveWithOpenPosition(emps : List) = emps |> List.filter(function | Manager({First = "Dave"}, []) -> true // [] matches an empty list. | Executive({First = "Dave"}, [], _) -> true | _ -> false) // '_' is a wildcard pattern that matches anything. // This handles the "or else" case. /// You can also use the shorthand function construct for pattern matching, /// which is useful when you're writing functions which make use of Partial Application. let private parseHelper (f: string -> bool * 'T) = f >> function | (true, item) -> Some item | (false, _) -> None let parseDateTimeOffset = parseHelper DateTimeOffset.TryParse let result = parseDateTimeOffset "1970-01-01" match result with | Some dto -> printfn "It parsed!" | None -> printfn "It didn't parse!" // Define some more functions which parse with the helper function. let parseInt = parseHelper Int32.TryParse let parseDouble = parseHelper Double.TryParse let parseTimeSpan = parseHelper TimeSpan.TryParse

活动模式是另一个可与模式匹配配合使用的强大构造。 它们使你能够将输入数据分区到自定义窗体,然后在模式匹配调用站点将其分解。 还可以将其参数化,从而允许将分区定义为函数。 下面演示了如何展开前面的示例以支持活动模式:

let (|Int|_|) = parseInt let (|Double|_|) = parseDouble let (|Date|_|) = parseDateTimeOffset let (|TimeSpan|_|) = parseTimeSpan /// Pattern Matching via 'function' keyword and Active Patterns often looks like this. let printParseResult = function | Int x -> printfn $"%d{x}" | Double x -> printfn $"%f{x}" | Date d -> printfn $"%O{d}" | TimeSpan t -> printfn $"%O{t}" | _ -> printfn "Nothing was parse-able!" // Call the printer with some different values to parse. printParseResult "12" printParseResult "12.045" printParseResult "12/28/2016" printParseResult "9:01PM" printParseResult "banana!" 选项

可区分联合类型的一个特殊情况是“选项类型”,它非常有用,因此是 F# 核心库的一部分。

选项类型用于表示以下两种情况中的一种:一个值或什么也没有。 它在特定操作可能也可能不会产生值的任何方案中使用。 然后,它会强制你考虑这两种情况,使其成为编译时问题,而不是运行时问题。 它们通常在 null 用于表示“无”的 API 中使用,因此在许多情况下无需担心 NullReferenceException。

module OptionValues = /// First, define a zip code defined via Single-case Discriminated Union. type ZipCode = ZipCode of string /// Next, define a type where the ZipCode is optional. type Customer = { ZipCode: ZipCode option } /// Next, define an interface type that represents an object to compute the shipping zone for the customer's zip code, /// given implementations for the 'getState' and 'getShippingZone' abstract methods. type IShippingCalculator = abstract GetState : ZipCode -> string option abstract GetShippingZone : string -> int /// Next, calculate a shipping zone for a customer using a calculator instance. /// This uses combinators in the Option module to allow a functional pipeline for /// transforming data with Optionals. let CustomerShippingZone (calculator: IShippingCalculator, customer: Customer) = customer.ZipCode |> Option.bind calculator.GetState |> Option.map calculator.GetShippingZone 度量单位

F# 的类型系统还能够通过度量单位为数值文本提供上下文。 使用度量单位可以将数值类型关联到某个单位(例如,米)中,并让函数对单位而不是数值文本执行工作。 这使编译器能够验证传入的数值文本类型在特定上下文中是否有意义,从而消除与此类工作关联的运行时错误。

module UnitsOfMeasure = /// First, open a collection of common unit names open Microsoft.FSharp.Data.UnitSystems.SI.UnitNames /// Define a unitized constant let sampleValue1 = 1600.0 /// Next, define a new unit type [] type mile = /// Conversion factor mile to meter. static member asMeter = 1609.34 /// Define a unitized constant let sampleValue2 = 500.0 /// Compute metric-system constant let sampleValue3 = sampleValue2 * mile.asMeter // Values using Units of Measure can be used just like the primitive numeric type for things like printing. printfn $"After a %f{sampleValue1} race I would walk %f{sampleValue2} miles which would be %f{sampleValue3} meters"

F# 核心库定义了许多 SI 单位类型和单位转换。 若要了解详细信息,请查看 FSharp.Data.UnitSystems.SI.UnitSymbols 命名空间。

对象编程

F# 完全支持通过类、接口、抽象类、继承等进行对象编程。

类是用于表示 .NET 对象的类型,这些对象可以具有属性、方法和事件作为其成员。

module DefiningClasses = /// A simple two-dimensional Vector class. /// /// The class's constructor is on the first line, /// and takes two arguments: dx and dy, both of type 'double'. type Vector2D(dx : double, dy : double) = /// This internal field stores the length of the vector, computed when the /// object is constructed let length = sqrt (dx*dx + dy*dy) // 'this' specifies a name for the object's self-identifier. // In instance methods, it must appear before the member name. member this.DX = dx member this.DY = dy member this.Length = length /// This member is a method. The previous members were properties. member this.Scale(k) = Vector2D(k * this.DX, k * this.DY) /// This is how you instantiate the Vector2D class. let vector1 = Vector2D(3.0, 4.0) /// Get a new scaled vector object, without modifying the original object. let vector2 = vector1.Scale(10.0) printfn $"Length of vector1: %f{vector1.Length}\nLength of vector2: %f{vector2.Length}"

定义泛型类也很简单。

module DefiningGenericClasses = type StateTracker


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3