拥抱未来:更优雅的 Swift 写法

栏目: Swift · 发布时间: 7年前

内容简介:拥抱未来:更优雅的 Swift 写法

Swift 是一个非常年轻的编程语言。我们应该如何做,才能够写出符合 Swift 语言习惯的代码呢?另一方面,由于 Objective-C 已经有三十多年的历史了。我们知道 Objective-C 应有的样子,以及代码带给我们的感觉应该是怎样的。不过,对我们这些 iOS 的先驱者来说,那时候的 Objective-C 和今日的 Objective-C 也有很大的不同。由此看来,Swift 的发展速度可能超乎您的想象。

在这个由 Daniel Steinberg 带来的 do {iOS} 演讲中,我们会在 Objective-C 代码以及其他在 Swift 之前出现的语言的基础之上,来探究 Swift 代码的编写方式,然后学习如何才能够写出让别人乐意去阅读的代码。快来加入我们,伴随着 Daniel 美味的、可阅读的以及可测试的“食谱 (recipe)” 比喻,一起来制作美味的 “意大利千层面 (lasagne)” 。

以“意大利千层面”做比喻

我想要用一个“意大利千层面”的食谱来作为示例。意大利千层面是拥有特定的上下文环境的:它的制作取决于您对制作步骤的了解。对于一名厨师来说,制作步骤中的每一个小步骤都意味着一件需要去精心准备的事情;而对于家庭烹饪来说,这可能又意味着别的涵义了。例如,对于家庭烹饪说,他们可能会去自行制作 “意式肉酱 (Bolognese Sauce)”,当然也有可能会直接去买一瓶肉酱来代替。

白酱 (Béchamel Sauce) 却又是另一回事了。事实上,白酱对于烹饪来说,是一个非常重要的酱料,用瓶装的并不是一个好主意。这就是我们常说的“妈妈的味道”(在传统的法式烹饪当中,有五种所谓的“基础调味酱”,在此基础之上又会延伸出一系列的酱汁)。

我们现在有了意大利千层面的食谱了,然后我们会开始制作所说的白酱。首先,在制作掺油面粉糊 (Roux) 的过程,放入黄油和面粉,然后倒上牛奶,等待其冷却之后品尝一下。这会给意大利千层面带来一个很美味的调料。

不管您在网上看过什么言论,有一点是毋庸置疑的,Objective-C 并没有到风烛残年的年纪。Objective-C 本身并没有什么错。这是一门很可爱的语言,但是如果您死守这门语言的话,您很可能会找不到工作。在我教的课程中有一个例子,制作一个简单的计时器。我想要给大家展示一下这个模型。

有这样一个 elapsedTime 方法,它会返回正在显示的时间。也就是得到回调。一般来说,我们可能会使用 alloc init 来创建 NSDate 的一个新实例。各位写 Swift 的还记得 alloc init 么?然后,我们会计算所用的时间。在 Swift 中我怀念的一个东西就是星号 (*) 了,因为我可以看一眼这段代码,然后立刻说出 NSDate 是引用类型,而 NSTimeInterval 不是。在 Swift 中,我们并没有这样的线索来清晰明了的看出引用类型和值类型的分别,这真的很遗憾。

- (NSTimeInterval)elapsedTime {
    NSDate *now = [[NSDate alloc] init]
    NSTimeInterval elapsedTime = [now timeIntervalSinceDate: self.startTime];
    return elapsedTime;
}

我们计算完所用的时间后,将其返回,这就是我们这个 elapsedTime 方法所做的。如果您喜欢精简的话,那么上面这三行代码可以用一行代码来代替:

return [self.startTime timeIntervalSinceNow];

我们使用了 timeIntervalSinceNow 这个方法,这非常的赞,只不过我们得到的结果是负数而已。

我们想要摒除负号。我们可以在前面放上一个负号来将这个负号消灭掉,但是每次有人看到这行代码的时候,他们必须要停下来思考:“为什么这前面出现了一个负号?”。

return -[self.startTime timeIntervalSinceNow];

这个负号所属的上下文环境缺失了。当您在查看这段代码的时候,您不是总能够记住这个负号添加在此的涵义。这个问题出在 Cocoa 所拥有的这个方法: timeIntervalSinceNow 当中,我们切实希望有这样一个名为 timeIntervalUntilNow 的方法。

我们知道如何使用类别来进行扩展,所以让我们一起来实现它。我们现在就不用担心命名冲突 (Name Spacing) 的问题了。

Receive news and updates from Realm straight to your inbox

@interface NSDate (TimeCalculations)
- (NSTimeInterval)timeIntervalUntilNow;
@end

这是我们的头文件,这也是我怀念的 Objective-C 特性。它会告诉我可以调用的具体部分。除此之外,我还是很喜欢 Swift 的,么么哒。

在这个 NSDate 类别的顶部,我用 TimerCalculations 来为其命名。我声明了我想要用的方法,然后跳转到实现文件当中去实现这个方法。我从 timeIntervalUntilNow 方法中所得到的就是 timeIntervalSinceNow 的负值。

@implementation NSDate (TimeCalculations)
- (NSTimeInterval)timeIntervalUntilNow {
    return -[self timeIntervalSinceNow]
  }
@end

您可能会问了:“好吧,Daniel,这里不是有同样的负号么?”。没错,不过现在它就位于 上下文环境 当中了。这个负号已经完全得到说明了。我可以说 timeIntervalUntilNowtimeIntervalSinceNow 的负值,它对于我以及来看我代码的其他人来说,是具有完整的意义的。 通过提供上下文环境,您就可以编写清晰明了的代码了,就像意大利千层面一样,每个馅料都包含在“面饼”里面

这里没有什么让人困惑的东西;我只需要调用 timeIntervalUntilNow ,一切清晰明了。

现在,如果我们不需要使用 self 、方括号以及分号的话,代码会变得更加简洁,但是或许我们可以更加简洁一些。让我们来看一看在 Swift 中这个模型会是什么样子的。

struct Timer {
    let startTime = NSDate()

    var elapsedTime: NSTimeInterval {
        return startTime.timeIntervalUntilNow
    }
}

在 Swift 中,这个模型变得着实简洁不少。我很喜欢 Swift 中的属性。在 Objective-C 中,我必须要导入头文件,然后声明属性,然后在 viewDidLoad 或者是别的某个地方对其进行初始化。而在 Swift 中我就可以像这样创建一个新的 NSDate 实例。

在我的 Timer 结构体中,我创建了 elapsedTime 属性。我不知道您有没有注意到,在 Swift 中我们倾向于使用计算属性来替代简单的方法实现。

extension NSDate {
    var timeIntervalUntilNow: NSTimeInterval {
        return -timeIntervalSinceNow
    }
}

我们只需要调用 startTimetimeIntervalUntilNow 即可,我们不需要再在类别中再次实现了。我们在同一个文件中放入这个扩展,这样它就不会污染我们的代码库,这样我们就完成了 NSDate 的扩展,然后返回 timeIntervalSinceNow 的相反值。

对我来说,这就是所谓的“调味酱汁 (Sauce on the side)” 的最好说明。这是我所用的“调味汁”当中的一种。在同一个文件当中,我提供了详情信息,因此我们在这里就完成了一个美味的“意大利千层面”的制作工作,因为代码是干净、结构紧凑、清晰并且可测试的。这有一点点作弊,因为我们是以 Objective-C 开始的,然后将代码转换为 Swift 而已,因此我们实际上没有做任何困难或者机智的东西,因此您可能仍然还是很困惑。

当您从 Objective-C 转来第一次学习 Swift 的时候,您觉得您只是用一种新的语法在编写 Objective-C 而已。但是,这并不是 Swift 的观念;您所做的只不过是“音译”而已。要去理解 Swift 的理念的话,我们必须要做一些不同的事情。

应用销售清单:Swift 的现场示例

假设,我想要跟踪一周内的应用销售情况。我需要 7 个数据点。我会使用 GameplayKit 来随机产生这些数据,然后我要去创建一个 SequenceType

import GameplayKit
struct AppSales: SequenceType {
    let numberOfDays: Int
    let randomDistribution = GKGaussianDistribution(lowestValue: 0, highestValue: 10)

    func generate() -> AnyGenerator<Int> {
        var count = 0
        return anyGenerator({
            if count++ < self.numberOfDays {
                return self.randomDistribution.nextInt()
            } else {
                return nil
            }
        })
    }
}

我喜欢这个来自 GameplayKit 的调用。它的意思是说:“我要创建一个从 0 到 10 之间的高斯分布 (Gaussian Distribution),这意味着大部分时间我会卖出五份拷贝。”我可以得到一个波斯分布,它有一个波峰,两侧的值稍微少一点。因此,我大概每天会卖掉五份拷贝。

这个 generate 方法是我们用来生成这些模拟数据的地方,从而完成我们 SequenceType 的构建。因此,如果一旦我没有获取到我指定的天数的话,我就会继续前进,从我的高斯分布中拿到下一个整数。否则,如果您跑完了一周的时间,那么就会返回 nil。

let lastWeeksSales = AppSales(numberOfDays: 7)

SequenceType 对于 Apple 来说着实非常重要。Map、filter、reduce 这些方法都被迁移到了 SequenceType 协议扩展当中。

我创建 lastWeeksSales 这个实例的感觉就跟创建数组一样,不过它还没有完全完成。记住,这是一个 SequenceType ,因此有些事情我仍然是还可以做的。

SequenceTpye 当中实现的快速枚举是一个非常好的方式;它只是不停地调用 next、next,直到其返回 nil 为止,枚举才会结束。这就是快速枚举的工作方式。因此 for in 工作的确实很棒。一旦我获取到上周的销售情况后,我就可以使用快速枚举来对其进行处理了。

let lastWeeksSales = AppSales(numberOfDays: 7)
for dailySales in lastWeeksSales {
    print(dailySales)
}

-> 6, 5, 6, 4, 6, 4, 3

我将我的日常销售记录打印了出来。注意到,它们是基本是围绕这 5 这个数字来创建的。现在我创建完了我的类型了。

另一件事是,由于 AppSales 是一个 SequenceType ,它支持所有的 map、filter、reduce 以及 flatMap 操作,但是如果你传递给它一个 SequenceType ,它返回的却是一个数组。如果您传递给的是一个 Int 序列的 SequenceType ,那么它会故意给您返回一个 Int 数组。

let lastWeeksSales = AppSales(numberOfDays: 7)
let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    Double(dailySales) * 1.99 * 0.70
}

我们来看一下 lastWeeksSales ,我们希望从这个销售记录中生成相应的收入。我们以 Int 数组开始,这个数组事实上还不是一个真正意义上的数组,而是一个 SequenceType ,我们通过闭包对其进行映射。我们会获取今日的销售额,然后将其转换成 Double 类型(您无法在 Swift 中使用自动类型推断)。

我卖出的价格是 $1.99,我由此获得了 70% 的利润。当我查看结果的时候,我所得到的 Double 类型会拥有一长串的小数位,这如果放到表格当中将会十分的丑陋。

我觉得如果我将 $1.99 指明是什么东西的话,这看起来会更好一些,因为对我来说 $1.99 意味着是美刀而已,而 70 则是意味着百分比。这对我来说要记忆的话是比较困难的,因为这里有两个 Double 值类型。我们没办法保证所有人的想法都是完全一致的,因为这里没有将我们的上下文环境带入进去。

let lastWeeksSales = AppSales(numberOfDays: 7)

let unitPrice = 1.99
let sellersPercentage = 0.70

let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    Double(dailySales) * 1.99 * 0.70
}

我将这个 $1.99 拿出来,然后使用一个常量来作为解释。unitPrice 意味着单位价格,对应着 1.99 美刀,所以我用这个 dailySales 来乘以这个 unitPrice 完成计算。

顺便一提,您无法在 Swift 输入 “.70” 这种形式,也无需明确指明这是一个 Double 类型。我需要对其进行解释。突然之间,这个计算变得更为清晰明了了。我获取我的日常销售额,然后乘以单位价格,然后再除乘以利润率,我所得到的就是我要的利润。

对单位价格和利润率,我分别创建了一个用以解释的常量。我们为什么不在闭包中执行相同的操作呢?为什么不将这个闭包单独提取出来,然后创建一个名为 revenuesForDCopiesSold 的函数呢?我通过对日常销售额进行映射,然后计算得到上一周拷贝销售的利润,这样我就完全解释了一切事情。我相信,当我再次回到这段代码的时候,我可以深入挖掘并找到我想要的东西。这给了我一个大致的概念,如果我想要深入探究的时候,我不会面临任何障碍。

let lastWeeksSales = AppSales(numberOfDays: 7)

let unitPrice = 1.99
let sellersPercentage = 0.70

func revenuesForCopiesSold(numberOfCopies: Int) -> Double {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    revenuesForCopiesSold(dailySales)
}

如果您没有使用过 $0 的话,您就无法称自己是一个 Swift 开发者。实际上,我很喜欢在我使用括号的地方使用这个用法。我知道这不是一个尾随闭包,但是我只是将方法名放到那里,我觉得这个做法看起来很不错。

let lastWeeksRevenues = lastWeeksSales.map( revenuesForCopiesSold )

如果对于一个映射来说大家觉得还不错的话,那么两个映射明显会更好一些。我准备去获得一个分布范围。它会从 -5 起步,到 15 为止,因此它的平均值仍然还是 5,不过我想要进一步从 5 这个数当中获取更多的值。

let randomDistribution = GKGaussianDistribution(lowestValue: -5, highestValue: 15)

Negative sales don’t make me happy, so I’d like 负数的销售额让我感到很不愉快,因此我想要将这些负数变为 0。我可以对拷贝进行匹配,只保留那些大于 0 的值。我只需要对元素进行匹配,保留大于 0 的值即可。非常棒,我在这里使用了 $0 ,这让我感觉自己像是一个棒棒的程序员。

let lastWeeksRevenues = lastWeeksSales.filter{$0 > 0}
                                      .map( revenuesForCopiesSold )

我现在有了一周的销售额了,我对其进行匹配,这样我可能拥有的值会少于 7 个。这或许会产生一些不好的影响,也许不会。在这种情况下,它并不会出问题(不过它很可能会出问题!)。我想要保证括号当中的数值始终保证有 7 个。因为匹配会改变数组的大小,因此我想要使用 filter 来替代,映射所有小于 0 的数据。

我只是改变了任何小于 0 的数值而已,然后我告诉自己“不要经常使用这个方法,这只是外部的标准偏差而已……”。我真的如此做了,所以我可以告诉大家我的做法:

let lastWeeksRevenues = lastWeeksSales.filter{$0 > 0 ? $0 : 0}
                                      .map( revenuesForCopiesSold )

因为到了现在,我真的感觉自己像是一个 程序员 了。但是我很容易将 $00 混淆,我很难记住什么是什么,因此很容易搞错,我在这里还使用了三元运算符。我担心在我的团队中会有人不能理解这段代码,因此我又进行了修改。

func negativeNumbersToZero(number: Int) -> Int {
    return max(0, number)
}

let lastWeeksRevenues = lastWeeksSales.map( negativeNumbersToZero )
                                      .map( revenuesForCopiesSold )

再说一次,我将计算、映射的对象提取了出来,放到一个单独的函数当中。 negativeNumbersToZero 函数将会返回 0 和传入的数值相比,更大的那个数,现在 lastWeeksRevenues 看起来就很棒了。我在第一个映射中将负数变为了 0,然后在后面找到对应拷贝销售额的利润。

我知道这看起来过于简化了,不过我很喜欢这样做。这对我来说很容易阅读,就感觉像是在看制作意大利千层面的食谱一样。然而,我很不喜欢这里的映射。

为什么我要将这个过程向外部暴露出来呢?为什么您需要知道我寻找这些东西的使用过程呢?为什么不将这些东西放到一个单独的函数当中呢?因此,我需要回到这个“食谱”当中,告诉大家我是怎么处理的。

首先,虽然我想要返回这个白酱(因为我超爱这个比喻)。再说一遍,我们使用这个函数: revenuesForCopiesSold ,它会返回一个表示不是很清晰的 Double 值。在 Swift 中,我们要为其创建一个新类型别名,因为和 Objective-C 相比,这里所做的代价花费得将会很小、也更容易。

typealias USDollars = Double

func revenuesForCopiesSold(numberOfCopies: Int) -> USDollars {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

我会说,请使用 USDollars 来替代 Double ,现在我就可以明确知道它表示的是什么了。我的 revenuesForCopiesSold 将会返回 USDollars ,因此我知道它衡量的是什么。

func toTheNearestPenny(dollarAmount: USDollars) -> USDollars {
    return round(dollarAmount * 100)/100
}

我还可能要将其转换成对应的便士。

现在,我们可以将它们整合到一个函数当中: revenuesInDollarsForCopiesSold ,它会获取您所卖出的拷贝数量,然后返回对应的 USDollars ,它会在底行执行相应的计算,使用拷贝的数量来计算拷贝销售的利润,然后使用对应的函数来将其转换为最近的便士。

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return toTheNearestPenny(revenuesForCopiesSold(numberOfCopies))
}

当我阅读这段代码的时候,可能会要将阅读方向反过来。我们从内部开始,然后逐步往外阅读。

有意义的自定义运算符及泛型

现在我很不情愿地向大家展示一个自定义运算符。

infix operator » {associativity left}

您可能会记得,这货像是意大利千层面食谱当中的流程符号。我打算定义一个中缀运算符 ( infix operator ),它会放在需要操作的两个东西之间。如果在一行文字中描述了一大堆步骤,我就对告诉您该以什么次序来将���行这���步骤,所以我会将其与左边进行关联。当您从左向右阅读的时候,我会先执行第一个步骤,然后它的结果会被传递给下一个步骤,这样就完成了与左边元素的关联。

func »<T, U>(input: T,
         transform: T -> U) -> U {
    return transform(input)
}

哦天,这里出现了泛型。 中缀运算符 需要获取两种输入:一些数据和一些函数,然后根据这两种输入来执行某些功能。我们可以跟踪这些类型,然后查看 T 类型输入是什么,以及 T -> U 类型是如何执行转换映射的。

这里的泛型用得很赞。也就是说,无论您输入的类型是什么,它都必须要匹配对应函数的参数。并且,无论函数的输出是什么,它都会成为这个运算符的返回类型。

在这基础上,教授使用泛型来进行函数式编程的人们会说:“好吧,显然这个函数只能够返回一个东西”。您必须要记住,“清晰明了”在于您的看法是如何的。在您仔细阅读并习惯之前,这并不是很清晰的。一旦有人向您解释之后,然后您再仔细查看,通过逐步熟悉之后,您就会豁然开朗了。

您唯一能做的就是将这个函数应用到输入当中,然后您就可以得到 U 类型的东西。然后,如果我在这个运算符的两边使用了某种元素以及某种函数的话,我就可以把这个函数应用到这个元素当汇总,最后它会返回输出给我。这样,我将依据次序来执行我所需要做的事情。

我觉得这有点尴尬,因为我创建完一个自定义运算符之后告诉大家,不要使用这玩意儿。但是很快您就会对此感觉良好了,因为我们会开始使用它。就像很多东西一样,用了才知道它好不好。

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return numberOfCopies
        » revenuesForCopiesSold
        » toTheNearestPenny
}

现在,我以我的拷贝数量开始,然后使用这个运算符来将我销售的拷贝利润放到队列当中,然后它会返回我计算后的某个东西,然后与左边的值进行关联。然后我再将 toTheNearestPenny 放到队列当中,最后就得到了结果。现在我感觉非常良好。

回到千层面样式的代码中

我计算完 lastWeeksRevenues 后,我就依次使用这两个映射函数。我想要将它转变为某种看起来像是队列清单一样的东西。我必须要考虑一些映射做了哪些事情,为什么要在这里使用映射,因此我打算移除映射函数。我会将这两个映射提取到一个函数当中。

func replaceNagiveSalesWithZeroSales(sales: [Int]) -> [Int] {
    return sales.map(negativeNumbersToZero)
}

func calculateRevenuesFromSales(sales: [Int]) -> [USDollars] {
    return sales.map(revenuesInDollarsForCopiesSold)
}

注意到,这仍然还是在执行相同的映射操作,但是我现在可以通过它的名称推断出它做了些什么。我会传递一个 Int 数组,然后我会通过映射将负数转为 0。这不仅变得更轻巧、更清晰,并且我还可以为其编写单元测试。

第二个方法同样也会接受一个 Int 数组,然后计算拷贝销售的利润。每个小方法都只做一件事。小的方法我可以通过输入进行相应的测试,然后看看它们的执行是怎么样的。除了……类型不匹配之外。

这就是为什么我之前使用的是 SequenceType ,因为 lastWeeksSales 是一个 SequenceType ,并不是 Int 数组。我的第一个方法需要的是 Int 数组。如果两个映射没关系的话,那么三个映射也是可以的。

func anArrayOfDailySales(rawSales: AppSales) -> [Int] {
    return rawSales.map{$0}
}

这会输出一个数组,然后它会获取您放入的每一个元素,然后将其作为下一个元素。所以它只需要执行转换就可以了。

我可以将这些函数链接在一起,这样看起来就非常像是一块千层面:

let lastWeeksRevenues = lastWeeksSales
                        » anArrayOfDailySales
                        » replaceNagiveSalesWithZeroSales
                        » calculateRevenuesFromSales

我会获取到 lastWeeksSales ,然后从中计算出日常销售额的数组,然后将负数替换为 0,然后计算这些销售额的利润,这样看起来就跟千层面一样。我感觉就是如此。这是一个可阅读的代码,我知道这让大家感觉很不习惯,不过我觉得大家应该学会爱上它。

当有人来看您的代码的话,他会很容易发现您在做什么。我知道您不会在工作中这样做,所以,您可以在小项目当中试一试。如果您像这样写的话,我是不知道您在说什么的:

let lastWeeksRevenues = lastWeeksSales
                        .map{$0 > 0 ? $0 : 0}
                        .map{round(Double($0) * unitPrice * sellersPercentage * 100) / 100}

您写的这个代码可能会让您感觉良好。我把这个代码放在这了,如果你理解不了,你就卷铺盖走人吧。有些人总是抱有这种想法,写出别人看不懂的代码出来。

如果您执意这么做的话,那么我不会想要在您的团队中工作的。我想要在一个能够写出大家可轻松阅读代码的团队当中,并且,这个代码我还乐意去读。我宁愿使用我的这个代码,当我需要知道更多的时候,我只需要将其放大:

let lastWeeksRevenues = lastWeeksSales
                        » anArrayOfDailySales
                        » replaceNagiveSalesWithZeroSales
                        » calculateRevenuesFromSales

func calculateRevenuesFromSales(sales: [Int]) -> [USDollars] {
    return sales.map(revenuesInDollarsForCopiesSold)
}

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return numberOfCopies
        » revenuesForCopiesSold
        » toTheNearestPenny
}

func revenuesForCopiesSold(numberOfCopies: Int) -> USDollars {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

每个部分都只关心自己的事情。当我在思考的时候,对我来说每个部分都非常的清晰。我可以在我的头脑中保持对代码的理解,但是我并不想要在我的头脑中放入太多的信息。如果有东西出了问题,我可以很快追溯到哪里出了问题,只需要发现测试失败就可以了。我们可以不停地进行精炼,直到代码完全清晰明了。

我们的代码现在变得非常的 干净 、清晰,并且 可以测试 。我对其感觉非常良好,因为我们为每个部分都提供了相应的上下文信息。

关于如何移除关于传感器方面的自定义运算符的有关内容,您可以查看文章顶部的视频中的此时间戳:


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

长尾理论

长尾理论

[美]克里斯•安德森 (Chris Anderson) / 乔江涛、石晓燕 / 中信出版社 / 2012 / 68.00元

网络经济正如火如荼地发展着,长尾理论无疑成为当代商务人士最为关注的焦点之一。不论是关于长尾理论的溢美还是论战,都代表了其备受关注的程度。 《长尾理论》是克里斯•安德森对这些争论的最明确的回答。在书中,他详细阐释了长尾的精华所在,指出商业和文化的未来不在于传统需求曲线上那个代表“畅销商品”的头部,而是那条代表“冷门商品”的经常被人遗忘的长尾。他还揭示了长尾现象是如何从工业资本主义原动力——规模......一起来看看 《长尾理论》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具