• 《C#与.NET3.5高级程序设计(第4版)》笔记9


    第九章 接口

    本章讨论了基于接口的编程。讨论了如何定义和实现接口,理解了它的优势。而后讨论了如获取接口引用、显式接口实现以及接口层次结构的构建。之后,研究了许多定义在.NET基类库中的标准接口。最后,介绍了接口类型如何用于创建回调机制,使得内存中两个对象能够进行双向通信。

    9.1 接口类型

    接口就是一组抽象成员的集合。所有成员都是纯粹的协议,在其中没有提供默认的实现。因此,接口中也不能有成员变量。接口可以被一个类或者结构来实现,也可以被其它接口继承。从概念上看,似乎接口和抽象类有类似的地方,这里简单对比一下:

    对比接口和抽象类:

    (1)抽象类中可以有许多抽象成员,也可以有构造函数、字段数据和非抽象成员等。而接口只能由抽象成员组成;

    (2)抽象基类中的抽象成员只能应用到派生类型,且一个派生类只能实现一个抽象基类。接口可以被任何层次结构、任何命名空间或任何程序集中的任何类型来实现,有较高的多态性;

    (3)抽象基类的每一个派生类型必须处理这一组抽象成员并且提供实现。接口则只有实现接口的类才需要实现接口,并实现其中每个方法。

    9.2 定义接口

    C#中用interface关键字定义接口。不应对接口中成员有任何修饰符(所有成员都是隐式公共的和抽象的),不合法,而且也没意义,在实现它的类型中,再按需要加上相应修饰符即可(显式实现的成员除外,它不允许添加任何修饰符,具体请看9.3节)。对接口本身可以用public或internal来修饰,不能用static修饰。对于成员来说,是不能用static来修饰的。

    注意,当定义接口时,不需要为成员定义实现作用域(连{}都不要有,加个分号即可),以下是例子:

    public interface Ipoint
    {
    byte test();//方法,没有实现
    int test2   //属性,没有实现 
    {get;set;}}

    当然,接口定义中还可以包含事件和索引器。就接口本身而言,它没有任何实际作用,只有被实现,比如实现接口的类或结构体,才是被最终应用的。因此这里还是强调,接口本身就是一种协议,他规定了那些实现它的类型必须要完成的工作,至于如何实现,那不是他的事情,是实现它的类型才需要关心的。

    以上是对接口的最基本的定义语法。事实上,接口还可以像类类型一样,在定义时继承其他接口(可以有多个),从而使接口形成继承链。这就是接口的层次结构(从关系上看,这些接口是纵向得扩展功能关系)。 

    层次结构:

    接口可以组织成接口层次结构。如果接口扩展了既有接口,它就继承了父类型定义的抽象成员,当然,派生接口不会继承真正的实现而是通过额外的抽象成员扩展了其自身的定义。从而一级级的继承,形成了接口继承链,任何需要实现接口的类类型,都要实现整个继承链上的所有接口声明的抽象成员。

        interface   Itest1
        {
            void test1();
        }
        interface Itest2
        {
            void test2();
        }
        interface Itest3 : Itest1 {
            void test3();
        }

    但是需要注意的是,和类类型的层次结构类似,在上级接口和下级接口中,若存在同名同参数且同返回类型的抽象成员时(也就是完全相同的抽象成员),那么会隐藏上级的那个成员,且编译时候会提示建议加上new关键字来显示隐藏上级成员。

        interface   Itest1
        {
            void test1();
        }
        interface Itest2 : Itest1
        {
            void test1();//这个test1隐藏了Itest1中的test1方法,最好加上new来显示隐藏。
        }

    这种纵向的继承,类似于类类型的继承,但是,比起类来,接口还可以一个接口扩展多个基接口,这就是接口的多层继承。(从关系上看,这些接口是横向并列的关系)。 

    多层继承:

    在这种多层继承的接口定义中需要使用逗号分隔符的列表,后面可以有多个接口,用逗号分开。

       interface   Itest1
        {
            void test1();
        }
        interface Itest2
        {
            void test2();
        }
    
        interface Itest3 : Itest1,Itest2//同时继承多个接口
        {
            void test3();
        }

    对于层次结构和多层继承的接口,无论是哪种形式的,最终定义得来的接口,都是包含了所有涉及接口的抽象成员的并集。从定义的角度来看,接口A被接口B继承,再被接口C继承的效果,和接口A和接口B被C直接继承的效果是一致的。

    9.4 实现接口

    上面说了,接口本身没有意义,必须得到类或接口类型的实现,方可发挥它的作用。类或接口通过支持接口类型来扩展自己的功能,这就需要在相应类型定义中使用逗号分隔符的列表,注意,若是类类型,则基类必须在冒号运算符的第一位,后面可以有多个接口,用逗号分开。

    要实现一个接口,就必须实现接口中所有成员,不能有选择的去实现其中的某个部分。

    interface Itest1
      {
         void test1();
         void test2();
      }
     class testclass:Itest1      
      {
        public void test1()
        {
         //some code
        }
        public void test2() 
        { 
         //some code 
        } 
      }
    

    注:这里所指的实现,即使是“实现”为一个abstract方法也是可以的,如:

     interface Itest1
        {
          void test1();
        }
    abstract class testclass:Itest1      
        {
         public abstract void test1();
        }

    注:还有一个限制,接口中的成员名称不得与实现它的类型名称相同。(否则就和类型的构造函数冲突了)

    VS.NET开发平台提供了智能标签,当实现一个接口时,在上述出现接口的位置点击智能感知的标签,可以自动生成存根代码供读者修改。

    在实现接口时,如果同时实现多个接口,或者实现的接口是一个层次结构和多层继承而来的,那么可能出现需要实现包含重复命名成员(比如多个接口都有一个draw的方法)的接口,这个时候就需要处理命名冲突

    这种情况下,如果在实现时仅仅提供了重复命名成员的一个实现方法,则所有具有这个成员的接口认为这个成员都属于自己,也就是这些成员均被一个成员实现,因此调用哪个接口的成员,都是一样的结果。

      interface A
        {
            void draw();
        }
        interface B
        {
            void draw();
        }
        interface C : A, B
        {
    
        }
        class test1 : A, B//同时实现多个接口
        {
            public void draw()
            {
                //some code
            }
        }
        class test2 : C//实现一个复合接口
        {
            public void draw()
            {
                //some code
            }
        }

    当然,有的时候并不想这样,而是需要为每个接口的这个成员都提供不同的实现,那么这个时候就需要使用显式接口实现语法来解决这种命名冲突。在实现时,方法名前面用点运算符加上对应的接口名称,显式定义这个方法仅仅是对这个接口的实现,如:

        interface A
        {
            void draw();
        }
        interface B
        {
            void draw();
        }
        class test : A, B
        {
            void A.draw()//显式实现的成员不能加任何修饰符
            {
                //some code
            }
            void B.draw()
            {
                //some code
            }
        }

    上面是由于类实现的多个接口中有重名成员名。还一种情况就是实现的接口本身由于层级结构和多层继承导致的一个子接口中实际上有多个重名成员了,但是这都不重要,归纳而言,无论哪种情况,实现类在实现这个重名方法时,如果不指定接口名,而仅仅实现了一个成员,那么所有具有这个成员的接口都认为是实现了它的成员,从而进行访问;若要分别实现,只需显式的实现每个方法就可以了,方法同上。这里给出一个具有代表性的例子:

    interface A
    {
        void drawA();
    }
    interface B
    {
        void drawB();
    }
    interface C : A
    {
        void drawC();
    }
    class test : C, B
    {
        void C.drawC()
        {
            //some code
        }
    
        void A.drawA()
        {
            //some code
        }
    
        void B.drawB()
        {
            //some code
        }
    }

    需要注意的是,对于显式实现的成员,它总是隐式私有的,不能修改其访问修饰符,并且这些成员在使用时不能在对象级别被访问(在9.4节中,无法采用第一种方式进行访问,可以理解,这种加了点运算符的方法,怎么用点运算符访问呢?),而是必须显式转换来提取接口引用后进行访问。

    利用这个特性,即使对于不存在命名冲突的方法也可以使用此方式,这对于希望在对象级别隐藏“高级”成员来说,是很好的方式,这使得对象用户使用点运算符的话无法访问这些功能,而那些高级行为的人,可以通过显式转换提取需要的接口。

    9.5 使用接口

    当接口被实现后,就是在各种应用中被使用了。由于接口是处于某个程序集的某个命名空间内的,因此记得使用接口时也要引用想用程序集,同时也可以使用using关键字简化使用接口的代码。

    直接实例化一个接口是不合法的(用new是不行的,因为接口没有构造函数的概念),可以理解,因为接口本身没有任何意义,实例化自然不行。那么如何使用接口呢,当然是使用实现了接口的类型喽,可以采用两种方式:

    (1)直接在对象级别调用接口成员

    interface A
    {
        void drawA();
    }
    
    class test : A
    {
        public void drawA()
        {
            //some code
        } 
    }
    class Program
    {
        static void Main(string[] args)
        {
            test mytest = new test();
            mytest.drawA();//直接调用实现的方法
        }
    }

    这是最直接的方式,因为类(或结构)实现了接口成员,自然能够通过类实例用点运算符访问。这在使用者了解这个类实现此接口的情况下适合,但是如果无法在编译时判断类型支持了哪些接口,这种方式就可能出错。而且,显示实现的接口不能用此方法(默认隐式私有),只能用第二种方法。

    (2)将实现接口的类转换为接口类型后访问

    这种方式就是将实现接口的类型转换为它实现的接口类型后,直接使用这个接口(当然此时接口已经被实现)。

    如何判断一个类型支持哪些接口呢?一种方式就是强制显式转换,但是如果不支持被请求的接口,则收到异常信息,必须增加try/catch块。

    circle c=new circle(); 
    Ipoint itpot=null; 
    try{
    itfpt=(Ipoint)c;//将实现接口的类强制转换为他所实现的接口
    } 
    catch(exception e)
    {}

    .NET中还有asis关键字(前面章节讲过了)用于获取接口的引用。

    as关键字:

    Ipoint itpot=c as Ipoint; 
    if(itpot!=null)
    {
    //转换成功 
    }
    else
    {}

    is关键字:

    circle c=new circle();
    Ipoint itpot=null;
    if(c is Ipoint)//可以转换 
    {
    itfpt=(Ipoint)c;
    }
    else
    {} 

    无论用了上述何种方法,都是从一个实现接口的类中提取了实现的那个接口的引用,从而通过这个引用,可以访问此类中接口的实现方法了。

    例如:

    Ipoint itfpt=(Ipoint)c;

    实际是c实现了接口Ipoint,这里是通过强制转换,提取了c中的关于Ipoint接口的已经被实现的方法,并将“实现”的接口的引用赋值给itfpt,itfpt是接口的实例化,当并非抽象接口的实例,而是具有c中相应已经实现的方法的“具体”接口了。

    注意,这里说的提取类中接口引用,其实是提取类的实例(对象)中接口引用哦。

    在使用层次结构接口时,可以在实现类中提取各层接口的引用,获得的每一层接口引用后,都可以访问这个接口和接口的所有上层接口的成员了(已经被类实现了的非抽象成员)。

    9.6 接口的应用

    这里所说的接口的应用,实际上是实现接口的类型的应用。因此,这里的接口,应该均视为已经被具体实现了的“接口”。

    既然接口也是一种有效的类型,那么接口可以作为参数,作为返回值,还可以定义接口类型的数组。

    显然,若参数类型为接口,那么一个从类中提取的接口引用或者这个类本身都可以作为参数传入,但是注意后者因为是作为可转换类型而传入,因此在方法体中若需要访问接口成员,上面的第一种访问方式没问题,若是第二种方式访问,则需要判断一下,对于后者需要对这个类进行转换后才可使用。

     class Program
        {
            static void Main(string[] args)
            {
                testclass1 class1 = new testclass1();
                testclass2 class2 = new testclass2();
                runprint(class1);
                runprint(class2);
                Console.Read();
            }
            static void runprint(Itest itest)
            {
                itest.print();
            }
        }
    
        interface Itest
        {
            void print();
        }
        class testclass1 : Itest
        {
    
            public void print()
            {
                Console.WriteLine("from testclass1");
            }
        }
        class testclass2 : Itest
        {
    
            public void print()
            {
                Console.WriteLine("from testclass2");
            }
        }

    从这个例子可以看出,不需要为每个类单独定义一个runprint方法来调用它们的print方法,因为他们都实现了Itest,因此只需要定义一个参数类型是Itest的方法即可。以后即使再定义更多的类,只要实现了Itest,这个runprint方法都可以使用。

    同理,作为返回值,可以返回类中提取的接口引用或者实现接口的类本身,但是使用时也要注意上面的因素,这里不再赘述。

    定义一个接口类型数组,那么它的成员应该是类中提取的接口引用或者实现接口的类。还是上面的接口Itest和实现它的类 testclass1和testclass2。

    Itest[] itest={new testclass1(),new testclass2};
    
    foreach(Itest i in itest)
    
    {
    Console.WriteLine("{0}",i.print());
    }

    9.7 常用接口

    上面介绍了接口的基本应用,实际上,.NET提供了许多接口,并且有许多地方已经用这些接口去方便实现一些功能。因此,如果我们的类型去实现这些接口,那么就可以利用现有的各种功能,这应该才是接口最经常用到的地方。以下介绍几个常用的。

    (1)构建可枚举类型

    C#支持foreach关键字来遍历一个一个集合中的每个对象。这是因为这些集合实现了IEnumerable和IEnumerator。其实,只要是支持GetEnumerator方法的类型都可以通过foreach结构进行运算(不实现接口也行,foreach会自动搜索这个方法,而不是是否实现了接口)。许多集合(如数组array类)因为实现了这个接口中的此方法,可以让我们foreach来遍历。

    foreach(car c in carlot) { //some code }

    这句必须是carlot对象中实现了GetEnumerator方法,才可以使用,否则这句无法通过编译。GetEnumerator方法如下:

    public class garage:IEnumerable 
    { 
    private car[] cararray=new car[2]; 
    public garage() 
    { 
    car[1]=new car(); 
    car[2]=new car(); 
    } 
    public IEnumerator GetEnumerator() 
    { 
    //这里应该返回一个IEnumerator 类型的值 
    //这里采用了现成的array的GetEnumerator方法,只是简单委托请求到array了。 
    return cararray.GetEnumerator(); 
    } 
    } 

    但是毕竟上面现成的GetEnumerator不多见,大多数时候,我们需要在GetEnumerator中详细实现以返回IEnumerator类型。

    在.NET2.0以后,还可以通过迭代器来构建使用foreach的类型。迭代器就是这样一个方法,它制定了容器内部项被foreach处理时该如何返回。虽然还是必须命名为GetEnumerator,返回值必须为IEnumerator,但是不需要实现原来那些接口了。

    public class garage:IEnumerable 
    { 
      private car[] cararray=new car[2]; 
      public garage() 
      { 
      car[1]=new car(); 
      car[2]=new car(); 
      } 
    public IEnumerator GetEnumerator() 
    { 
       foreach(car c in cararray) 
       { 
       yield return c; 
       } 
    } 
    } 

    这回使用了yield返回语法向调用方返回每个car对象。yield关键字用来向调用方foreach指定返回值。当然,也可以用下面的方式,但显然比起上面的,不够灵活。

    public IEnumerator GetEnumerator() 
    { 
    yield return cararray[0]; 
    yield return cararray[1]; 
    } 
    其实,yield关键字从技术上说可以结合任何方法一起使用,无论方法名是什么,这个方法叫做命名迭代器,它的独特之处在于
    可以接受许多参数,只要返回值依然是IEnumerator类型,而不是IEnumerator的兼容类型: 
    public IEnumerator MyGetEnumerator(bool returnreversed) 
    { 
    if(returnreversed) 
    { 
    yield return cararray[1]; 
    yield return cararray[0]; 
    } 
    else 
    return cararray.GetEnumerator();
    } 
    
    但是,因为foreach默认是找GetEnumerator方法的,所以我们自定义的方法必须显式的制定才可以:

    foreach(car c in carlot.MyGetEnumerator(ture))   { //some code}

    这样,由于有了命名迭代器,一个自定义容器可以定义多重方式来请求返回的集(通过方法名称、方法参数)。

    最后总结一下可枚举对象的构建吧:默认情况下,只有实现了返回值类型是IEnumerator 的方法,才能用foreach关键字进行遍历。许多类型已经用实现了IEnumerable接口而获得了foreach的支持。自定义类型必须自己实现这个接口(或直接实现一个返回类型是IEnumerator 的方法),如果这个方法是GetEnumerator,则foreach中不必指定它即可,如果是其他名字的,就必须显式的指定方法名才可使用foreach。对于自定义类型实现的这个方法,可以使用yield关键字来简化实现的逻辑。

    疑问:如果不用yield关键字也不想利用现有的集合的GetEnumerator方法,如何实现(也就是想一一实现IEnumerable和IEnumerator)?

    答:可以参考http://hi.baidu.com/sanchengxiaoge/blog/item/0b41402803501ef798250a6b.html

    (2)构建可克隆类型

    如果想使自定义类型支持向调用方法返回自身同样副本的能力,需要实现标准的ICloneable接口,此接口的方法Clone放回当前对象的副本(object类型)。但是这个接口尚受到 非议,主要是因为官方没有规定这个接口是应该必须返回一个深副本(对象内部引用类型必须是具有相同状态的全新状态)还是浅副本(内部引用指向堆中的同一对象),因此这很容易混淆。在实现过程中,应该根据需要进行实现(一般应该实现深副本,这样才符合克隆的含义)。
    要实现一个浅副本,比较容易:

        class point : ICloneable
        {
            public int a;
            public string str;
            public DataTable tab = null;
            public point()
            {
                a = 0;
                str = "";
                tab = new DataTable();
            }
            public point(int value,string strvalue,string tabname)
            {
                a = value;
                str = strvalue;
                tab = new DataTable(tabname);
            } 
            public object Clone()
            {
                return this.MemberwiseClone();//MemberwiseClone是object的一个成员,用于返回对象的浅副本。   
            }
        }
        class Program
        {
            static void Main(string[] args)
            {
                point point1 = new point(5, "hello world", "myTableName");
                point point2 = (point)point1.Clone();
                point2.tab.TableName = "new name";
                point2.str = "new str";
                point2.a = 10;
                Console.Read();
            }
        }

    对于浅复制,它具有的值类型将独立于原对象,改变其中一个的值类型(如上例的point2.a改变了,不会影响point1的a值),另一个不受影响;而对于引用类型的成员变量,两个对象其实具有相同的对于成员变量的引用,改变一个,另一个也受影响(point2.tab改变了,同时影响point1的tab也会相应变化)。但是有一个例外,就是string类型,如果改变被克隆对象的string类型的值(point2.str改变了,不会影响point1的str ),另一个对象也不受影响,这主要是由于string类型的特殊性决定。因为其中一个对象的string的值改变,实际上是产生了一个新的string类型,引用也发生变化,另一个对象若没有改变string的值时,指向的依然是对方对象的原string值,不受影响。
    注意,如果这个类包含了引用类型的成员变量,则MemberwiseClone将这些引用复制到对象中,如果想真正实现深复制,需要在克隆过程中创建任意引用类型变量的新实例。
    深复制的一般方法是,首先生成一个浅复制,然后更改这个浅复制中所有引用类型,让他们指向各自新的实例,因此,就可以独立于被克隆对象,产生一个深复制。
    比如上面的进行更改:

    public object Clone()
      {
        point newpoint= (point)this.MemberwiseClone();//先获取浅复制版本
        newpoint.tab = new DataTable("new name");//以此将引用类型重新指定引用
        return newpoint;
      }

    (3)构建可比较类型

    Array有一个静态方法Sort,它能够比较一个数组中各元素的大小,进行排序,这对于一个普通数组是可以直接使用的:
    String[] MyStr=New String[]{"a","b","c"};
    Array.Sort(MyStr);
    这主要是String实现了接口IComparable(许多具有可比较性的类型都实现了此接口,此接口就有一个成员CompareTo)。但是,如数组中的成员复杂,或一个自定义的类型,那么由于其中可以比较的东西不唯一,因此,这时候就需要对这个自定义类型首先实现一个可比较的接口(成员是CompareTo),让Sort方法调用CompareTo来实现排序。重写一般格式:

    public class car:IComprable   
    {   
    int CompareTo(object obj)   
    {   
    car temp=(car)obj;   
    if(this.carid>temp.carid)   
    return 1;   
    if(this.carid<temp.carid)   
    return -1;   
    else  
    return 0;   
    }   
    }  

    其实利用string类型已经实现这个接口,可以委托给它的这个方法:

    int CompareTo(object obj)
    {
    car temp=(car)obj;
    return this.carid.CompareTo(temp.carid);
    }

    可以看到,如果一个类型实现了这个接口,那么就可以在各种提供比较的功能中直接使用。可是有一个问题,比如一个person类,我想对他按年龄比较,也想它按工龄比较,甚至按退休年龄也比较,那么怎么办呢?由于一个类只能实现一次CompareTo,显然这时这个接口就排不上用场了。这就是要介绍的另外一个接口:这时候就需要另一个接口:IComparer,他有一个方法Compare。与上一个接口不同,这个接口不是在要排序的类型中,而是在许多辅助类中实现,其中每一个排序各有一个依据(如按名字排序,按ID号排序):
    public class petnamecomparer:IComparer//辅助类   
    {   
    int compare(object o1,object o2)   
    {   
    car t1=(car)o1;   
    car t2=(car)o2;   
    return string.compare(t1.petname,t2.petname);   
    }   
    }  

    在调用时,比如Main中:

    static  void Main()
    {
    Car[] myautos=new car[]{new car("w",1);,new car("x",2);}
    Array.sort(myautos,new petnamecomparer());//Sort支持这种方式的排序
    //也可以用Array.sort(myautos,(IComparer) new petnamecomparer());
    }
    

    可见,还可以定义更多辅助类,以对这种类型进行比较。其实还可以在需要排序的类中定义一个自定义静态属性辅助上面的操作。

    public class car:IComprable   
    {   
    public static IComparer sortbyname   
    {   
    get{return (IComparer) new petnamecomparer();}   
    }   
    }   

    使用时:
    Array.sort(myautos,car.sortbyname);
    比较这两种形式,new petnamecomparer()其实不就是car.sortbyname的返回值吗。

    9.8 回调接口

    接口可以用作回调机制,这种技术使得对象可以使用一组公共成员进行双向对话。(在没有了解事件等概念前,如果看这个节,总觉得一头雾水,主要是不知道这究竟有何用,当了解事件概念后,方知回调接口实际上就是一种事件机制,但.NET已经提供了一种正式的结构来构建机制,比如委托、事件和Lambda表达式,因此这种接口回调机制并非正式的结构。)因此,建议如果想了解这一概念,等到了解上面提到的其他概念后,回来再了解这一概念,就会更容易掌握。

    回调接口工作原理是:

    定义一个回调接口(要做什么),并由一个叫做接收器对象的辅助对象来实现(怎么做)。事件发送者(产生事件的主体)会在合适时候调用接收器,创建类时,通过增加,它在类中保留事件的列表,然后再发生某个事件时调用这个事件的实现类,不用时,删除这个事件即可。这里不以书中代码为例,而是按照我的理解,描述了回调接口整个工作过程。

      class Program
        {
            static void Main(string[] args)
            {
                person person1 = new person();
                class1 myclass1 = new class1();
                class2 myclass2 = new class2();
                person1.addSaySomething(myclass1);//告诉person事件发生时应执行myclass1的代码
                person1.addSaySomething(myclass2);//告诉person事件发生时应执行myclass2的代码
                person1.Say();//事件发生,同时执行了myclass1和myclass2代码
                person1.delSaySomething(myclass2);//移除一个接收器myclass2
                person1.Say();//事件发生,只有myclass1被执行
                Console.Read();
            }
        }
        interface Itest //回调接口,定义了该做什么
        {
            void SaySomething();
        }
        class class1 : Itest//接收器对象class1,告诉了如何做Itest规定的事情
        {
            void Itest.SaySomething()
            {
                Console.WriteLine("hello,I am from class1");
            }
        }
        class class2 : Itest//接收器对象class2,也告诉了如何做Itest规定的事情
        {
            void Itest.SaySomething()
            {
                Console.WriteLine("hello,I am from class2");
            }
        }
         class person//事件的发送者
        {
            ArrayList saySomethingList = new ArrayList();
            public void addSaySomething(Itest itest)//添加接收器对象
            {
                saySomethingList.Add(itest);
            }
            public void delSaySomething(Itest itest)//移除接收器对象
            {
                saySomethingList.Remove(itest);
            }
            public void Say()//触发事件,至于执行哪些代码,由saySomethingList包含的接受器对象集合决定
            {
                foreach (var item in saySomethingList)
                {
                    ((Itest)item).SaySomething();
                }
            }
        }
  • 相关阅读:
    kaggle CTR预估
    基于大规模语料的新词发现算法【转自matix67】
    vim E437: terminal capability "cm" required
    makefile 中的符号替换($@、$^、$<、$?)
    【转】Makefile 中:= ?= += =的区别
    python urljoin问题
    python 写文件刷新缓存
    python Popen卡死问题
    nohup 日志切割
    换行和回车野史
  • 原文地址:https://www.cnblogs.com/lerit/p/1831322.html
Copyright © 2020-2023  润新知