Reload Original PagePrint PageEmail Page

如何设计一门语言(二)——什么是坑(b) - λ-calculus(惊愕到手了欧耶,GetBlogPostIds.aspx) - C++博客

我从来没有在别的语言的粉里面看见过这么容易展示人性丑陋一面的粉,就算是从十几年前开始的C++和C对喷,GC和非GC对喷,静态类型动态类型对喷的时候,甚至是云风出来喷C++黑得那么惊天动地的时候,都没有发生过这么脑残的事情。这种事情只发生在go语言的脑残粉的身上,这究竟代表什么呢?想学go语言的人最好小心一点了,学怎么用go没关系,go学成了因为受不了跳到别的语言去也没关系,就算是抖M很喜欢被折腾所以坚持用go也没关系,但是把自己学成了脑残粉,自己的心智发生不可逆转的变换,那就不好了。

当然,上一篇文章最后那个例子应该是我还没说清楚,所以有些人有这种“加上一个虚析构函数就可以了”的错觉也是情有可原的。Base* base = new Derived;之后你去delete没问题,是因为析构函数你还可以声明成虚的。但是Base* base = new Derived[10];之后你去delete[]发生了问题,是因为Derived和Base的长度不一样,所以当你开始试图计算&base[1]的时候,你实际上是拿到了第一个Derived对象的中间的一个位置,根本不是第二个Derived。这个时候你在上面做各种操作(譬如调用析构函数),你连正确的this指针都拿不到,你再怎么虚也是没用的。不过VC++单纯做delete[]的话,在这种情况下是不会有问题的,我猜它内部不仅记录了数组的长度,还记录了每一个元素的尺寸。当然,你直接用bases[1]->DoSomething()的时候,出事是必须的。

所以今天粉丝群在讨论昨天的这个例子的时候,我们的其中一位菊苣就说了一句话:

我也很赞同。反正C++已经有各种内置类型了,譬如typeid出来的按个东西(我给忘了)啊,initialization_list啊,range什么的。为什么就不给new T[x]创建一个类型呢?不过反正都已经成为现实了,没事就多用用vector和shared_ptr吧,不要想着什么自己new自己delete了。

今天我们来讲一个稍微“高级”一点点的坑。这是我在工作之后遇到的一个现实的例子。当然,语言的坑都摆在那里,人往坑里面跳都肯定是因为自己知道的东西还不够多造成的。但是坑有三种,第一种是很明显的,只要遵守一些看起来很愚蠢但是却很有效的原则(譬如说if(1 == a)…)就可以去除的。第二种坑是因为你不知道一些高级的知识(譬如说lambda和变量揉在一起的生命周期的事情)从而跳坑的。第三种纯粹就是由于远见不够了——譬如说下面的例子。

在春光明媚的一个早上,我接到了一个新任务,要跟另一个不是我们组的人一起写一个图像处理的pipeline的东西。这种pipeline的节点无非就是什么直方图啊,卷积啊,灰度还有取边缘什么的。于是第一天开会的时候,我拿到了一份spec,上面写好了他们设计好但是还没开始写的C++的interface(没错,就是那种就算只有一个实现也要用interface的那种流派),让我回去看一看,过几天跟他们一起把这个东西实现出来。当然,这些interface里面肯定会有矩阵:

template<typename T>
class IMatrix
{
public:
    virtual ~IMatrix(){}

    virtual T* GetData()=0;
    virtual int GetRows()=0;
    virtual int GetColumns()=0;
    virtual int GetStride()=0;
    virtual T Get(int r, int c)=0;
    virtual void Set(int r, int c, T t)=0;
};

其实说实话,IMatrix这么写的确没什么大问题。于是我们就很愉快的工作了几天,然后把这些纯粹跟数学有关的算法都完成了,然后就开始做卷积的事情了。卷积所需要的那一堆数字其实说白了他不是矩阵,但因为为这种东西专门做一个类也没意义,所以我们就用行列一样多的矩阵来当filter。一开始的接口定义成这个样子,因为IBitmap可能有不同的储存方法,所以如何做卷积其实只有IBitmap的实现自己才知道:

template<typename TChannel>
class IBitmap
{
......
    virtual void Apply(IMatrix<float>& filter)=0;
......
};

于是我们又愉快的度过了几天,直到有一天有个人跳出来说:“Apply里面又不能修改filter,为什么不给他做成const的?”于是他给我们展示了他修改后的接口:

template<typename TChannel>
class IBitmap
{
......
    virtual void Apply(IMatrix<const float>& filter)=0;
......
};

我依稀还记得我当时的表情就是这样子的→囧。

语言的类型系统是一件特别复杂的事情,特别是像C++这种,const T<a, b, c>和T<const a, const b, cont c>是两个不一样的类型的。一们语言,凡是跟优美的理论每一个不一致的地方都是一个坑,区别只是有些坑严重有些坑不严重。当然上面这个不是什么大问题,因为真的按照这个接口写下去,最后会因为发现创建不了IMatrix<const float>的实现而作罢。

而原因很简单,因为一般来说IMatrix<T>的实现内部都有一个T*代表的数组。这个时候给你换成了const float,你会发现,你的Set函数在也没办法把const float写进const float*了,然后就挂了。所以正确的方法当然是:

virtual void Apply(const IMatrix<float>& filter)=0;

不过在展开这个问题之前,我们先来看一个更加浅显易懂的“坑”,是关于C#的值类型的。譬如说我们有一天需要做一个超高性能的包含四大力学的粒子运动模拟程序——咳咳——总之从一个Point类型开始。一开始是这么写的(C# 5.0):

struct Point
{
    public int x;
    public int y;
}

var ps = new Point[] { new Point { x = 1, y = 2 } };
ps[0].x = 3;

已开始运作的很好,什么事情都没有发生,ps[0]里面的Point也被很好的更改了。但是有一天,情况变了,粒子之间会开始产生和消灭新的粒子了,于是我把数组改成了List:

var ps = new List<Point> { new Point { x = 1, y = 2 } };
ps[0].x = 3;

结果编译器告诉我最后一行出了一个错误:

Cannot modify the return value of 'System.Collections.Generic.List<ArrayTest2.Program.Point>.this[int]' because it is not a variable

C#这语言就是牛逼啊,我用了这么久,就只找出这个“不起眼的问题”的同时,还是一个编译错误,所以用C#的时候根本没有办法用错啊。不过想想,VB以前这么多人用,除了on error resume next以外也没用出什么坑,可见Microsoft设计语言的功力比某狗公司那是要强多了。

于是我当时就觉得很困惑,随手写了另一个类来验证这个问题:

class PointBox
{
    public int Number { get; set; }
    public Point Point { get; set; }
}

var box = new PointBox() { Number = 1, Point = new Point { x = 1, y = 2 } };
box.Number += 3;
box.Point.x = 5;

结果倒数第二行过了,倒数第一行还是编译错误了。为什么同样是属性,int就可以+=3,Point就不能改一个field非得创建一个新的然后再复制进去呢?后来只能得到一个结论,数组可以List不可以,属性可以+=不能改field(你给Point定义一个operator+,那你对box.Point做+=也是可以的),只能认为是语言故意这么设计的了。

写到这里,我想起以前在MSDN上看过的一句话,说一个结构,如果超过了16个字节,就建议最好不要做成struct。而且以前老赵写了一个小sample也证明大部分情况下用struct其实还不如用class快。当然至于是为什么我这里就不详细展开了,我们来讲语法上的问题。

在C#里面,struct和class的区别,就是值和引用的区别。C#专门做了值类型和引用类型,值类型不能转成引用(除非box成object或nullable或lazy等),引用类型不能转值类型。值不可以继承,引用可以继承。我们都知道,你一个类继承自另一个类,目的说到底都是为了覆盖几个虚函数。如果你不是为了覆盖虚函数然后你还要继承,八成是你的想法有问题。如果继承了,你就可以从子类的引用隐式转换成父类的引用,然后满足里氏代换原则。

但是C#的struct是值类型,也就是说他不是个引用(指针),所以根本不存在什么拿到父类引用的这个事情。既然你每一次见到的类型都是他真正的类型(而不像class,你拿到IEnumerable<T>,他可能是个List<T>),那也没有什么必要有虚函数了。如果你在struct里面不能写虚函数,那还要继承干什么呢?所以struct就不能继承。

然后我们来看一看C#的属性。其实C#的operator[]不是一个操作符,跟C++不一样,他是当成属性来看待的。属性其实是一个语法糖,其中的getter和setter是两个函数。所以如果一个属性的类型是struct,那么getter的返回值也是struct。一个函数返回struct是什么意思呢?当然是把结果【复制】一遍然后返回出去了。所以当我们写box.Point.x=5的时候,其实等价于box.get_Point().x=5。你拿到的Point是复制过的,你对一个复制过的struct来修改里面的x,自然不能影响box里面存放着的那个Point。所以这是一个无效语句,C#干脆就给你定了个编译错误了。不过你可能会问,List和Array大家都是operator[]也是一个属性,那为什么Array就可以呢?答案很简单,Array是有特殊照顾的……

不过话说回来,为什么很少人遇到这个问题?想必是能写成struct的这些东西,作为整体来讲本身是一个状态。譬如说上面的Point,x和y虽然是分离的,但是他们并不独立代表状态,代表状态的是Point这个整体。Tuple(这是个class,不过其实很像struct)也一样,还有很多其他的.net framework里面定义的struct也一样。因此就算我们经常构造List<Point>这种东西,我们也很少要去单独修改其中一个element的一部分。

那为什么struct不干脆把每一个field都做成不可修改的呢?原因是这样做完全没有带来什么好处,反正你误操作了,总是会有编译错误的。还有些人可能会问,为什么在struct里面的方法里,对this的操作就会产生影响呢?这个问题问得太好了,因为this是一个本质上是“指针”的东西。

这就跟上一篇文章所讲的东西不一样了。这篇文章的两个“坑”其实不能算坑,因为他们最终都会引发编译错误来迫使你必须修改代码。所以说,如果C++的new T[x]返回的东西是一个货真价实的数组,那该多好啊。数组质检科从来没有什么转换的。就像Delphi的array of T也好,C#的T[]也好,C++的array<T>或者vector<T>也好,你从来都不能把一个T的数组转成U的数组,所以也就没有这个问题了。所以在用C++的时候,STL有的东西,你就不要自己撸了,只伤身体没好处的……

那么回到一开始说的const的问题。我们在C++里面用const,一般都是有两个目的。第一个是用const引用来组织C++复制太多东西,第二个是用const指针来代表某些值是不打算让你碰的。但是一个类里面的函数会做什么我们并不知道,所以C++给函数也加上了const。这样对于一个const T的类型,你只能调用T里面所有标记了const的函数了。而且对于标记了const的成员函数,他的this指针也是const T* const类型的,而不是以前的T* const类型。

那类似的问题在C#里面是怎么解决的呢?首先第一个问题是不存在的,因为C#复制东西都是按bit复制的,你的struct无论怎么写都一样。其次,C#没有const类型,所以如果你想表达一个类不想让别人修改,那你就得把那些“const”的部分抽出来放在父类或父接口里面了。所以现在C#里面除了IList<T>类型以外,还有IReadOnlyList<T>。其实我个人觉得IReadOnlyList这个名字不好,因为这个对象说不定底下是个List,你用着用着,因为别人改了这个List导致你IReadOnlyList读出来的东西变了,迷惑性就产生了。所以在这种情况下,我宁可叫他IReadableList。他是Readable的,只是把write的接口藏起来的你碰不到而已。

所以,const究竟是在修饰什么的呢?如果是修饰类型的话,跟下面一样让函数的参数的类型都变成const,似乎完全是没有意义的:

int Add(const int a, const int b);

或者更甚,把返回值也改成const:

const int Add(const int a, const int b);

那他跟

究竟有什么区别呢?或许在函数内部你不能把参数a和b当变量用了。但是在函数的外部,其实这三个函数调用起来都没有任何区别。而且根据我们的使用习惯来讲,const修饰的应该不是一个类型,而是一个变量才对。我们不希望IBitmap::Apply函数里面会修改filter,所以函数签名就改成了:

virtual void Apply(const IMatrix<float>& filter)=0;

我们不希望用宏来定义常数,所以我们会在头文件里面这么写:

const int ADD = 1;
const int SUB = 2;
const int MUL = 3;
const int DIV = 4;
const int PUSH = 5;
const int POP = 6;

或者干脆用enum:

enum class Instructions
{
    ADD = 1,
    SUB,
    MUL,
    DIV,
    PUSH,
    POP

::...

免责声明:
当前网页内容, 由 大妈 ZoomQuiet 使用工具: ScrapBook :: Firefox Extension 人工从互联网中收集并分享;
内容版权归原作者所有;
本人对内容的有效性/合法性不承担任何强制性责任.
若有不妥, 欢迎评注提醒:

或是邮件反馈可也:
askdama[AT]googlegroups.com


点击注册~> 获得 100$ 体验券: DigitalOcean Referral Badge

订阅 substack 体验古早写作:


关注公众号, 持续获得相关各种嗯哼:
zoomquiet


自怼圈/年度番新

DU22.4
关于 ~ DebugUself with DAMA ;-)
粤ICP备18025058号-1
公安备案号: 44049002000656 ...::